diff --git a/cli/cliui/gitauth.go b/cli/cliui/externalauth.go similarity index 88% rename from cli/cliui/gitauth.go rename to cli/cliui/externalauth.go index 7c42160da7..2e416ae3b5 100644 --- a/cli/cliui/gitauth.go +++ b/cli/cliui/externalauth.go @@ -11,12 +11,12 @@ import ( "github.com/coder/coder/v2/codersdk" ) -type GitAuthOptions struct { +type ExternalAuthOptions struct { Fetch func(context.Context) ([]codersdk.TemplateVersionExternalAuth, error) FetchInterval time.Duration } -func GitAuth(ctx context.Context, writer io.Writer, opts GitAuthOptions) error { +func ExternalAuth(ctx context.Context, writer io.Writer, opts ExternalAuthOptions) error { if opts.FetchInterval == 0 { opts.FetchInterval = 500 * time.Millisecond } @@ -38,7 +38,7 @@ func GitAuth(ctx context.Context, writer io.Writer, opts GitAuthOptions) error { return nil } - _, _ = fmt.Fprintf(writer, "You must authenticate with %s to create a workspace with this template. Visit:\n\n\t%s\n\n", auth.Type.Pretty(), auth.AuthenticateURL) + _, _ = fmt.Fprintf(writer, "You must authenticate with %s to create a workspace with this template. Visit:\n\n\t%s\n\n", auth.DisplayName, auth.AuthenticateURL) ticker.Reset(opts.FetchInterval) spin.Start() @@ -66,7 +66,7 @@ func GitAuth(ctx context.Context, writer io.Writer, opts GitAuthOptions) error { } } spin.Stop() - _, _ = fmt.Fprintf(writer, "Successfully authenticated with %s!\n\n", auth.Type.Pretty()) + _, _ = fmt.Fprintf(writer, "Successfully authenticated with %s!\n\n", auth.DisplayName) } return nil } diff --git a/cli/cliui/gitauth_test.go b/cli/cliui/externalauth_test.go similarity index 84% rename from cli/cliui/gitauth_test.go rename to cli/cliui/externalauth_test.go index 3adbfc0510..32deb72905 100644 --- a/cli/cliui/gitauth_test.go +++ b/cli/cliui/externalauth_test.go @@ -15,7 +15,7 @@ import ( "github.com/coder/coder/v2/testutil" ) -func TestGitAuth(t *testing.T) { +func TestExternalAuth(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) @@ -25,12 +25,13 @@ func TestGitAuth(t *testing.T) { cmd := &clibase.Cmd{ Handler: func(inv *clibase.Invocation) error { var fetched atomic.Bool - return cliui.GitAuth(inv.Context(), inv.Stdout, cliui.GitAuthOptions{ + return cliui.ExternalAuth(inv.Context(), inv.Stdout, cliui.ExternalAuthOptions{ Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionExternalAuth, error) { defer fetched.Store(true) return []codersdk.TemplateVersionExternalAuth{{ ID: "github", - Type: codersdk.ExternalAuthProviderGitHub, + DisplayName: "GitHub", + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), Authenticated: fetched.Load(), AuthenticateURL: "https://example.com/gitauth/github", }}, nil diff --git a/cli/create.go b/cli/create.go index 8c5bc6b3e7..9511322d55 100644 --- a/cli/create.go +++ b/cli/create.go @@ -265,7 +265,7 @@ func prepWorkspaceBuild(inv *clibase.Invocation, client *codersdk.Client, args p return nil, err } - err = cliui.GitAuth(ctx, inv.Stdout, cliui.GitAuthOptions{ + err = cliui.ExternalAuth(ctx, inv.Stdout, cliui.ExternalAuthOptions{ Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionExternalAuth, error) { return client.TemplateVersionExternalAuth(ctx, templateVersion.ID) }, diff --git a/cli/create_test.go b/cli/create_test.go index 2cd4c9abaa..31996176e8 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -613,7 +613,8 @@ func TestCreateWithGitAuth(t *testing.T) { OAuth2Config: &testutil.OAuth2Config{}, ID: "github", Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.ExternalAuthProviderGitHub, + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", }}, IncludeProvisionerDaemon: true, }) diff --git a/cli/server.go b/cli/server.go index b0ec9f999b..5d7ab4c5ff 100644 --- a/cli/server.go +++ b/cli/server.go @@ -98,85 +98,6 @@ import ( "github.com/coder/wgtunnel/tunnelsdk" ) -// ReadGitAuthProvidersFromEnv is provided for compatibility purposes with the -// viper CLI. -// DEPRECATED -func ReadGitAuthProvidersFromEnv(environ []string) ([]codersdk.GitAuthConfig, error) { - // The index numbers must be in-order. - sort.Strings(environ) - - var providers []codersdk.GitAuthConfig - for _, v := range clibase.ParseEnviron(environ, "CODER_GITAUTH_") { - tokens := strings.SplitN(v.Name, "_", 2) - if len(tokens) != 2 { - return nil, xerrors.Errorf("invalid env var: %s", v.Name) - } - - providerNum, err := strconv.Atoi(tokens[0]) - if err != nil { - return nil, xerrors.Errorf("parse number: %s", v.Name) - } - - var provider codersdk.GitAuthConfig - switch { - case len(providers) < providerNum: - return nil, xerrors.Errorf( - "provider num %v skipped: %s", - len(providers), - v.Name, - ) - case len(providers) == providerNum: - // At the next next provider. - providers = append(providers, provider) - case len(providers) == providerNum+1: - // At the current provider. - provider = providers[providerNum] - } - - key := tokens[1] - switch key { - case "ID": - provider.ID = v.Value - case "TYPE": - provider.Type = v.Value - case "CLIENT_ID": - provider.ClientID = v.Value - case "CLIENT_SECRET": - provider.ClientSecret = v.Value - case "AUTH_URL": - provider.AuthURL = v.Value - case "TOKEN_URL": - provider.TokenURL = v.Value - case "VALIDATE_URL": - provider.ValidateURL = v.Value - case "REGEX": - provider.Regex = v.Value - case "DEVICE_FLOW": - b, err := strconv.ParseBool(v.Value) - if err != nil { - return nil, xerrors.Errorf("parse bool: %s", v.Value) - } - provider.DeviceFlow = b - case "DEVICE_CODE_URL": - provider.DeviceCodeURL = v.Value - case "NO_REFRESH": - b, err := strconv.ParseBool(v.Value) - if err != nil { - return nil, xerrors.Errorf("parse bool: %s", v.Value) - } - provider.NoRefresh = b - case "SCOPES": - provider.Scopes = strings.Split(v.Value, " ") - case "APP_INSTALL_URL": - provider.AppInstallURL = v.Value - case "APP_INSTALLATIONS_URL": - provider.AppInstallationsURL = v.Value - } - providers[providerNum] = provider - } - return providers, nil -} - func createOIDCConfig(ctx context.Context, vals *codersdk.DeploymentValues) (*coderd.OIDCConfig, error) { if vals.OIDC.ClientID == "" { return nil, xerrors.Errorf("OIDC client ID must be set!") @@ -568,14 +489,14 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } } - gitAuthEnv, err := ReadGitAuthProvidersFromEnv(os.Environ()) + extAuthEnv, err := ReadExternalAuthProvidersFromEnv(os.Environ()) if err != nil { - return xerrors.Errorf("read git auth providers from env: %w", err) + return xerrors.Errorf("read external auth providers from env: %w", err) } - vals.GitAuthProviders.Value = append(vals.GitAuthProviders.Value, gitAuthEnv...) + vals.ExternalAuthConfigs.Value = append(vals.ExternalAuthConfigs.Value, extAuthEnv...) externalAuthConfigs, err := externalauth.ConvertConfig( - vals.GitAuthProviders.Value, + vals.ExternalAuthConfigs.Value, vals.AccessURL.Value(), ) if err != nil { @@ -816,7 +737,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. if vals.Telemetry.Enable { gitAuth := make([]telemetry.GitAuth, 0) // TODO: - var gitAuthConfigs []codersdk.GitAuthConfig + var gitAuthConfigs []codersdk.ExternalAuthConfig for _, cfg := range gitAuthConfigs { gitAuth = append(gitAuth, telemetry.GitAuth{ Type: cfg.Type, @@ -2242,3 +2163,101 @@ func ConfigureHTTPServers(inv *clibase.Invocation, cfg *codersdk.DeploymentValue return httpServers, nil } + +// ReadExternalAuthProvidersFromEnv is provided for compatibility purposes with +// the viper CLI. +func ReadExternalAuthProvidersFromEnv(environ []string) ([]codersdk.ExternalAuthConfig, error) { + providers, err := parseExternalAuthProvidersFromEnv("CODER_EXTERNAL_AUTH_", environ) + if err != nil { + return nil, err + } + // Deprecated: To support legacy git auth! + gitProviders, err := parseExternalAuthProvidersFromEnv("CODER_GITAUTH_", environ) + if err != nil { + return nil, err + } + return append(providers, gitProviders...), nil +} + +// parseExternalAuthProvidersFromEnv consumes environment variables to parse +// external auth providers. A prefix is provided to support the legacy +// parsing of `GITAUTH` environment variables. +func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]codersdk.ExternalAuthConfig, error) { + // The index numbers must be in-order. + sort.Strings(environ) + + var providers []codersdk.ExternalAuthConfig + for _, v := range clibase.ParseEnviron(environ, prefix) { + tokens := strings.SplitN(v.Name, "_", 2) + if len(tokens) != 2 { + return nil, xerrors.Errorf("invalid env var: %s", v.Name) + } + + providerNum, err := strconv.Atoi(tokens[0]) + if err != nil { + return nil, xerrors.Errorf("parse number: %s", v.Name) + } + + var provider codersdk.ExternalAuthConfig + switch { + case len(providers) < providerNum: + return nil, xerrors.Errorf( + "provider num %v skipped: %s", + len(providers), + v.Name, + ) + case len(providers) == providerNum: + // At the next next provider. + providers = append(providers, provider) + case len(providers) == providerNum+1: + // At the current provider. + provider = providers[providerNum] + } + + key := tokens[1] + switch key { + case "ID": + provider.ID = v.Value + case "TYPE": + provider.Type = v.Value + case "CLIENT_ID": + provider.ClientID = v.Value + case "CLIENT_SECRET": + provider.ClientSecret = v.Value + case "AUTH_URL": + provider.AuthURL = v.Value + case "TOKEN_URL": + provider.TokenURL = v.Value + case "VALIDATE_URL": + provider.ValidateURL = v.Value + case "REGEX": + provider.Regex = v.Value + case "DEVICE_FLOW": + b, err := strconv.ParseBool(v.Value) + if err != nil { + return nil, xerrors.Errorf("parse bool: %s", v.Value) + } + provider.DeviceFlow = b + case "DEVICE_CODE_URL": + provider.DeviceCodeURL = v.Value + case "NO_REFRESH": + b, err := strconv.ParseBool(v.Value) + if err != nil { + return nil, xerrors.Errorf("parse bool: %s", v.Value) + } + provider.NoRefresh = b + case "SCOPES": + provider.Scopes = strings.Split(v.Value, " ") + case "APP_INSTALL_URL": + provider.AppInstallURL = v.Value + case "APP_INSTALLATIONS_URL": + provider.AppInstallationsURL = v.Value + case "DISPLAY_NAME": + provider.DisplayName = v.Value + case "DISPLAY_ICON": + provider.DisplayIcon = v.Value + } + providers[providerNum] = provider + } + return providers, nil +} diff --git a/cli/server_test.go b/cli/server_test.go index dbbff56d9a..7034f2fa33 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -49,11 +49,50 @@ import ( "github.com/coder/coder/v2/testutil" ) +func TestReadExternalAuthProvidersFromEnv(t *testing.T) { + t.Parallel() + t.Run("Valid", func(t *testing.T) { + t.Parallel() + providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{ + "CODER_EXTERNAL_AUTH_0_ID=1", + "CODER_EXTERNAL_AUTH_0_TYPE=gitlab", + "CODER_EXTERNAL_AUTH_1_ID=2", + "CODER_EXTERNAL_AUTH_1_CLIENT_ID=sid", + "CODER_EXTERNAL_AUTH_1_CLIENT_SECRET=hunter12", + "CODER_EXTERNAL_AUTH_1_TOKEN_URL=google.com", + "CODER_EXTERNAL_AUTH_1_VALIDATE_URL=bing.com", + "CODER_EXTERNAL_AUTH_1_SCOPES=repo:read repo:write", + "CODER_EXTERNAL_AUTH_1_NO_REFRESH=true", + "CODER_EXTERNAL_AUTH_1_DISPLAY_NAME=Google", + "CODER_EXTERNAL_AUTH_1_DISPLAY_ICON=/icon/google.svg", + }) + require.NoError(t, err) + require.Len(t, providers, 2) + + // Validate the first provider. + assert.Equal(t, "1", providers[0].ID) + assert.Equal(t, "gitlab", providers[0].Type) + + // Validate the second provider. + assert.Equal(t, "2", providers[1].ID) + assert.Equal(t, "sid", providers[1].ClientID) + assert.Equal(t, "hunter12", providers[1].ClientSecret) + assert.Equal(t, "google.com", providers[1].TokenURL) + assert.Equal(t, "bing.com", providers[1].ValidateURL) + assert.Equal(t, []string{"repo:read", "repo:write"}, providers[1].Scopes) + assert.Equal(t, true, providers[1].NoRefresh) + assert.Equal(t, "Google", providers[1].DisplayName) + assert.Equal(t, "/icon/google.svg", providers[1].DisplayIcon) + }) +} + +// TestReadGitAuthProvidersFromEnv ensures that the deprecated `CODER_GITAUTH_` +// environment variables are still supported. func TestReadGitAuthProvidersFromEnv(t *testing.T) { t.Parallel() t.Run("Empty", func(t *testing.T) { t.Parallel() - providers, err := cli.ReadGitAuthProvidersFromEnv([]string{ + providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{ "HOME=/home/frodo", }) require.NoError(t, err) @@ -61,7 +100,7 @@ func TestReadGitAuthProvidersFromEnv(t *testing.T) { }) t.Run("InvalidKey", func(t *testing.T) { t.Parallel() - providers, err := cli.ReadGitAuthProvidersFromEnv([]string{ + providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{ "CODER_GITAUTH_XXX=invalid", }) require.Error(t, err, "providers: %+v", providers) @@ -69,7 +108,7 @@ func TestReadGitAuthProvidersFromEnv(t *testing.T) { }) t.Run("SkipKey", func(t *testing.T) { t.Parallel() - providers, err := cli.ReadGitAuthProvidersFromEnv([]string{ + providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{ "CODER_GITAUTH_0_ID=invalid", "CODER_GITAUTH_2_ID=invalid", }) @@ -78,7 +117,7 @@ func TestReadGitAuthProvidersFromEnv(t *testing.T) { }) t.Run("Valid", func(t *testing.T) { t.Parallel() - providers, err := cli.ReadGitAuthProvidersFromEnv([]string{ + providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{ "CODER_GITAUTH_0_ID=1", "CODER_GITAUTH_0_TYPE=gitlab", "CODER_GITAUTH_1_ID=2", diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index 565815d944..9a30127be7 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -331,17 +331,17 @@ func main() { // Complete the auth! gitlabAuthed.Store(true) }() - return cliui.GitAuth(inv.Context(), inv.Stdout, cliui.GitAuthOptions{ + return cliui.ExternalAuth(inv.Context(), inv.Stdout, cliui.ExternalAuthOptions{ Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionExternalAuth, error) { count.Add(1) return []codersdk.TemplateVersionExternalAuth{{ ID: "github", - Type: codersdk.ExternalAuthProviderGitHub, + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), Authenticated: githubAuthed.Load(), AuthenticateURL: "https://example.com/gitauth/github?redirect=" + url.QueryEscape("/gitauth?notify"), }, { ID: "gitlab", - Type: codersdk.ExternalAuthProviderGitLab, + Type: codersdk.EnhancedExternalAuthProviderGitLab.String(), Authenticated: gitlabAuthed.Load(), AuthenticateURL: "https://example.com/gitauth/gitlab?redirect=" + url.QueryEscape("/gitauth?notify"), }}, nil diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ab794794f6..e523745f5e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -602,7 +602,7 @@ const docTemplate = `{ } } }, - "/externalauth/{externalauth}": { + "/external-auth/{externalauth}": { "get": { "security": [ { @@ -637,7 +637,7 @@ const docTemplate = `{ } } }, - "/externalauth/{externalauth}/device": { + "/external-auth/{externalauth}/device": { "get": { "security": [ { @@ -2768,7 +2768,7 @@ const docTemplate = `{ } } }, - "/templateversions/{templateversion}/externalauth": { + "/templateversions/{templateversion}/external-auth": { "get": { "security": [ { @@ -6725,13 +6725,13 @@ const docTemplate = `{ "clibase.Regexp": { "type": "object" }, - "clibase.Struct-array_codersdk_GitAuthConfig": { + "clibase.Struct-array_codersdk_ExternalAuthConfig": { "type": "object", "properties": { "value": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.GitAuthConfig" + "$ref": "#/definitions/codersdk.ExternalAuthConfig" } } } @@ -7978,15 +7978,15 @@ const docTemplate = `{ "type": "string" } }, + "external_auth": { + "$ref": "#/definitions/clibase.Struct-array_codersdk_ExternalAuthConfig" + }, "external_token_encryption_keys": { "type": "array", "items": { "type": "string" } }, - "git_auth": { - "$ref": "#/definitions/clibase.Struct-array_codersdk_GitAuthConfig" - }, "http_address": { "description": "HTTPAddress is a string because it may be set to zero to disable.", "type": "string" @@ -8203,6 +8203,9 @@ const docTemplate = `{ "device": { "type": "boolean" }, + "display_name": { + "type": "string" + }, "installations": { "description": "AppInstallations are the installations that the user has access to.", "type": "array", @@ -8210,9 +8213,6 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.ExternalAuthAppInstallation" } }, - "type": { - "type": "string" - }, "user": { "description": "User is the user that authenticated with the provider.", "allOf": [ @@ -8237,6 +8237,64 @@ const docTemplate = `{ } } }, + "codersdk.ExternalAuthConfig": { + "type": "object", + "properties": { + "app_install_url": { + "type": "string" + }, + "app_installations_url": { + "type": "string" + }, + "auth_url": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "device_code_url": { + "type": "string" + }, + "device_flow": { + "type": "boolean" + }, + "display_icon": { + "description": "DisplayIcon is a URL to an icon to display in the UI.", + "type": "string" + }, + "display_name": { + "description": "DisplayName is shown in the UI to identify the auth config.", + "type": "string" + }, + "id": { + "description": "ID is a unique identifier for the auth config.\nIt defaults to ` + "`" + `type` + "`" + ` when not provided.", + "type": "string" + }, + "no_refresh": { + "type": "boolean" + }, + "regex": { + "description": "Regex allows API requesters to match an auth config by\na string (e.g. coder.com) instead of by it's type.\n\nGit clone makes use of this by parsing the URL from:\n'Username for \"https://github.com\":'\nAnd sending it to the Coder server to match against the Regex.", + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "token_url": { + "type": "string" + }, + "type": { + "description": "Type is the type of external auth config.", + "type": "string" + }, + "validate_url": { + "type": "string" + } + } + }, "codersdk.ExternalAuthDevice": { "type": "object", "properties": { @@ -8257,23 +8315,6 @@ const docTemplate = `{ } } }, - "codersdk.ExternalAuthProvider": { - "type": "string", - "enum": [ - "azure-devops", - "github", - "gitlab", - "bitbucket", - "openid-connect" - ], - "x-enum-varnames": [ - "ExternalAuthProviderAzureDevops", - "ExternalAuthProviderGitHub", - "ExternalAuthProviderGitLab", - "ExternalAuthProviderBitBucket", - "ExternalAuthProviderOpenIDConnect" - ] - }, "codersdk.ExternalAuthUser": { "type": "object", "properties": { @@ -8330,53 +8371,6 @@ const docTemplate = `{ } } }, - "codersdk.GitAuthConfig": { - "type": "object", - "properties": { - "app_install_url": { - "type": "string" - }, - "app_installations_url": { - "type": "string" - }, - "auth_url": { - "type": "string" - }, - "client_id": { - "type": "string" - }, - "device_code_url": { - "type": "string" - }, - "device_flow": { - "type": "boolean" - }, - "id": { - "type": "string" - }, - "no_refresh": { - "type": "boolean" - }, - "regex": { - "type": "string" - }, - "scopes": { - "type": "array", - "items": { - "type": "string" - } - }, - "token_url": { - "type": "string" - }, - "type": { - "type": "string" - }, - "validate_url": { - "type": "string" - } - } - }, "codersdk.GitSSHKey": { "type": "object", "properties": { @@ -10018,11 +10012,17 @@ const docTemplate = `{ "authenticated": { "type": "boolean" }, + "display_icon": { + "type": "string" + }, + "display_name": { + "type": "string" + }, "id": { "type": "string" }, "type": { - "$ref": "#/definitions/codersdk.ExternalAuthProvider" + "type": "string" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7a7ee0a69e..a97a865aae 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -512,7 +512,7 @@ } } }, - "/externalauth/{externalauth}": { + "/external-auth/{externalauth}": { "get": { "security": [ { @@ -543,7 +543,7 @@ } } }, - "/externalauth/{externalauth}/device": { + "/external-auth/{externalauth}/device": { "get": { "security": [ { @@ -2430,7 +2430,7 @@ } } }, - "/templateversions/{templateversion}/externalauth": { + "/templateversions/{templateversion}/external-auth": { "get": { "security": [ { @@ -5961,13 +5961,13 @@ "clibase.Regexp": { "type": "object" }, - "clibase.Struct-array_codersdk_GitAuthConfig": { + "clibase.Struct-array_codersdk_ExternalAuthConfig": { "type": "object", "properties": { "value": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.GitAuthConfig" + "$ref": "#/definitions/codersdk.ExternalAuthConfig" } } } @@ -7130,15 +7130,15 @@ "type": "string" } }, + "external_auth": { + "$ref": "#/definitions/clibase.Struct-array_codersdk_ExternalAuthConfig" + }, "external_token_encryption_keys": { "type": "array", "items": { "type": "string" } }, - "git_auth": { - "$ref": "#/definitions/clibase.Struct-array_codersdk_GitAuthConfig" - }, "http_address": { "description": "HTTPAddress is a string because it may be set to zero to disable.", "type": "string" @@ -7351,6 +7351,9 @@ "device": { "type": "boolean" }, + "display_name": { + "type": "string" + }, "installations": { "description": "AppInstallations are the installations that the user has access to.", "type": "array", @@ -7358,9 +7361,6 @@ "$ref": "#/definitions/codersdk.ExternalAuthAppInstallation" } }, - "type": { - "type": "string" - }, "user": { "description": "User is the user that authenticated with the provider.", "allOf": [ @@ -7385,6 +7385,64 @@ } } }, + "codersdk.ExternalAuthConfig": { + "type": "object", + "properties": { + "app_install_url": { + "type": "string" + }, + "app_installations_url": { + "type": "string" + }, + "auth_url": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "device_code_url": { + "type": "string" + }, + "device_flow": { + "type": "boolean" + }, + "display_icon": { + "description": "DisplayIcon is a URL to an icon to display in the UI.", + "type": "string" + }, + "display_name": { + "description": "DisplayName is shown in the UI to identify the auth config.", + "type": "string" + }, + "id": { + "description": "ID is a unique identifier for the auth config.\nIt defaults to `type` when not provided.", + "type": "string" + }, + "no_refresh": { + "type": "boolean" + }, + "regex": { + "description": "Regex allows API requesters to match an auth config by\na string (e.g. coder.com) instead of by it's type.\n\nGit clone makes use of this by parsing the URL from:\n'Username for \"https://github.com\":'\nAnd sending it to the Coder server to match against the Regex.", + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "token_url": { + "type": "string" + }, + "type": { + "description": "Type is the type of external auth config.", + "type": "string" + }, + "validate_url": { + "type": "string" + } + } + }, "codersdk.ExternalAuthDevice": { "type": "object", "properties": { @@ -7405,23 +7463,6 @@ } } }, - "codersdk.ExternalAuthProvider": { - "type": "string", - "enum": [ - "azure-devops", - "github", - "gitlab", - "bitbucket", - "openid-connect" - ], - "x-enum-varnames": [ - "ExternalAuthProviderAzureDevops", - "ExternalAuthProviderGitHub", - "ExternalAuthProviderGitLab", - "ExternalAuthProviderBitBucket", - "ExternalAuthProviderOpenIDConnect" - ] - }, "codersdk.ExternalAuthUser": { "type": "object", "properties": { @@ -7478,53 +7519,6 @@ } } }, - "codersdk.GitAuthConfig": { - "type": "object", - "properties": { - "app_install_url": { - "type": "string" - }, - "app_installations_url": { - "type": "string" - }, - "auth_url": { - "type": "string" - }, - "client_id": { - "type": "string" - }, - "device_code_url": { - "type": "string" - }, - "device_flow": { - "type": "boolean" - }, - "id": { - "type": "string" - }, - "no_refresh": { - "type": "boolean" - }, - "regex": { - "type": "string" - }, - "scopes": { - "type": "array", - "items": { - "type": "string" - } - }, - "token_url": { - "type": "string" - }, - "type": { - "type": "string" - }, - "validate_url": { - "type": "string" - } - } - }, "codersdk.GitSSHKey": { "type": "object", "properties": { @@ -9065,11 +9059,17 @@ "authenticated": { "type": "boolean" }, + "display_icon": { + "type": "string" + }, + "display_name": { + "type": "string" + }, "id": { "type": "string" }, "type": { - "$ref": "#/definitions/codersdk.ExternalAuthProvider" + "type": "string" } } }, diff --git a/coderd/coderd.go b/coderd/coderd.go index c02f9a8d3d..b8cf095777 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -547,7 +547,7 @@ func New(options *Options) *API { // Register callback handlers for each OAuth2 provider. // We must support gitauth and externalauth for backwards compatibility. - for _, route := range []string{"gitauth", "externalauth"} { + for _, route := range []string{"gitauth", "external-auth"} { r.Route("/"+route, func(r chi.Router) { for _, externalAuthConfig := range options.ExternalAuthConfigs { // We don't need to register a callback handler for device auth. @@ -616,7 +616,7 @@ func New(options *Options) *API { r.Get("/{fileID}", api.fileByID) r.Post("/", api.postFile) }) - r.Route("/externalauth/{externalauth}", func(r chi.Router) { + r.Route("/external-auth/{externalauth}", func(r chi.Router) { r.Use( apiKeyMiddleware, httpmw.ExtractExternalAuthParam(options.ExternalAuthConfigs), @@ -689,7 +689,7 @@ func New(options *Options) *API { r.Get("/schema", templateVersionSchemaDeprecated) r.Get("/parameters", templateVersionParametersDeprecated) r.Get("/rich-parameters", api.templateVersionRichParameters) - r.Get("/externalauth", api.templateVersionExternalAuth) + r.Get("/external-auth", api.templateVersionExternalAuth) r.Get("/variables", api.templateVersionVariables) r.Get("/resources", api.templateVersionResources) r.Get("/logs", api.templateVersionLogs) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 6cc0d5132e..47803524a5 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -906,7 +906,7 @@ func RequestExternalAuthCallback(t *testing.T, providerID string, client *coders return http.ErrUseLastResponse } state := "somestate" - oauthURL, err := client.URL.Parse(fmt.Sprintf("/externalauth/%s/callback?code=asd&state=%s", providerID, state)) + oauthURL, err := client.URL.Parse(fmt.Sprintf("/external-auth/%s/callback?code=asd&state=%s", providerID, state)) require.NoError(t, err) req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) require.NoError(t, err) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 43238d3f71..17bb9c539e 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -643,7 +643,7 @@ CREATE TABLE template_versions ( message character varying(1048576) DEFAULT ''::character varying NOT NULL ); -COMMENT ON COLUMN template_versions.external_auth_providers IS 'IDs of Git auth providers for a specific template version'; +COMMENT ON COLUMN template_versions.external_auth_providers IS 'IDs of External auth providers for a specific template version'; COMMENT ON COLUMN template_versions.message IS 'Message describing the changes in this version of the template, similar to a Git commit message. Like a commit message, this should be a short, high-level description of the changes in this version of the template. This message is immutable and should not be updated after the fact.'; diff --git a/coderd/database/migrations/000158_external_auth.up.sql b/coderd/database/migrations/000158_external_auth.up.sql index 3c9b787b23..52fc1977e3 100644 --- a/coderd/database/migrations/000158_external_auth.up.sql +++ b/coderd/database/migrations/000158_external_auth.up.sql @@ -22,4 +22,6 @@ FROM COMMENT ON VIEW template_version_with_user IS 'Joins in the username + avatar url of the created by user.'; +COMMENT ON COLUMN template_versions.external_auth_providers IS 'IDs of External auth providers for a specific template version'; + COMMIT; diff --git a/coderd/database/models.go b/coderd/database/models.go index dda46ce282..ab6d8861ee 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1857,7 +1857,7 @@ type TemplateVersionTable struct { Readme string `db:"readme" json:"readme"` JobID uuid.UUID `db:"job_id" json:"job_id"` CreatedBy uuid.UUID `db:"created_by" json:"created_by"` - // IDs of Git auth providers for a specific template version + // IDs of External auth providers for a specific template version ExternalAuthProviders []string `db:"external_auth_providers" json:"external_auth_providers"` // Message describing the changes in this version of the template, similar to a Git commit message. Like a commit message, this should be a short, high-level description of the changes in this version of the template. This message is immutable and should not be updated after the fact. Message string `db:"message" json:"message"` diff --git a/coderd/externalauth.go b/coderd/externalauth.go index 8e69f3f239..577fdfa0b0 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -23,7 +23,7 @@ import ( // @Tags Git // @Param externalauth path string true "Git Provider ID" format(string) // @Success 200 {object} codersdk.ExternalAuth -// @Router /externalauth/{externalauth} [get] +// @Router /external-auth/{externalauth} [get] func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) { config := httpmw.ExternalAuthParam(r) apiKey := httpmw.APIKey(r) @@ -33,7 +33,7 @@ func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) { Authenticated: false, Device: config.DeviceAuth != nil, AppInstallURL: config.AppInstallURL, - Type: config.Type.Pretty(), + DisplayName: config.DisplayName, AppInstallations: []codersdk.ExternalAuthAppInstallation{}, } @@ -82,7 +82,7 @@ func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) { // @Tags Git // @Param externalauth path string true "External Provider ID" format(string) // @Success 204 -// @Router /externalauth/{externalauth}/device [post] +// @Router /external-auth/{externalauth}/device [post] func (api *API) postExternalAuthDeviceByID(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) @@ -169,7 +169,7 @@ func (api *API) postExternalAuthDeviceByID(rw http.ResponseWriter, r *http.Reque // @Tags Git // @Param externalauth path string true "Git Provider ID" format(string) // @Success 200 {object} codersdk.ExternalAuthDevice -// @Router /externalauth/{externalauth}/device [get] +// @Router /external-auth/{externalauth}/device [get] func (*API) externalAuthDeviceByID(rw http.ResponseWriter, r *http.Request) { config := httpmw.ExternalAuthParam(r) ctx := r.Context() @@ -255,7 +255,7 @@ func (api *API) externalAuthCallback(externalAuthConfig *externalauth.Config) ht redirect := state.Redirect if redirect == "" { // This is a nicely rendered screen on the frontend - redirect = fmt.Sprintf("/externalauth/%s", externalAuthConfig.ID) + redirect = fmt.Sprintf("/external-auth/%s", externalAuthConfig.ID) } http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } diff --git a/coderd/externalauth/config.go b/coderd/externalauth/externalauth.go similarity index 52% rename from coderd/externalauth/config.go rename to coderd/externalauth/externalauth.go index 08f2ef6db9..4db6ec695c 100644 --- a/coderd/externalauth/config.go +++ b/coderd/externalauth/externalauth.go @@ -15,6 +15,7 @@ import ( "golang.org/x/xerrors" "github.com/google/go-github/v43/github" + xgithub "golang.org/x/oauth2/github" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -35,9 +36,13 @@ type Config struct { // ID is a unique identifier for the authenticator. ID string // Type is the type of provider. - Type codersdk.ExternalAuthProvider + Type string // DeviceAuth is set if the provider uses the device flow. DeviceAuth *DeviceAuth + // DisplayName is the name of the provider to display to the user. + DisplayName string + // DisplayIcon is the path to an image that will be displayed to the user. + DisplayIcon string // NoRefresh stops Coder from using the refresh token // to renew the access token. @@ -113,7 +118,7 @@ validate: // to the read replica in time. // // We do an exponential backoff here to give the write time to propagate. - if c.Type == codersdk.ExternalAuthProviderGitHub && r.Wait(retryCtx) { + if c.Type == string(codersdk.EnhancedExternalAuthProviderGitHub) && r.Wait(retryCtx) { goto validate } // The token is no longer valid! @@ -171,7 +176,7 @@ func (c *Config) ValidateToken(ctx context.Context, token string) (bool, *coders } var user *codersdk.ExternalAuthUser - if c.Type == codersdk.ExternalAuthProviderGitHub { + if c.Type == string(codersdk.EnhancedExternalAuthProviderGitHub) { var ghUser github.User err = json.NewDecoder(res.Body).Decode(&ghUser) if err == nil { @@ -217,7 +222,7 @@ func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk return nil, false, nil } installs := []codersdk.ExternalAuthAppInstallation{} - if c.Type == codersdk.ExternalAuthProviderGitHub { + if c.Type == string(codersdk.EnhancedExternalAuthProviderGitHub) { var ghInstalls struct { Installations []*github.Installation `json:"installations"` } @@ -245,50 +250,158 @@ func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk return installs, true, nil } +type DeviceAuth struct { + ClientID string + TokenURL string + Scopes []string + CodeURL string +} + +// AuthorizeDevice begins the device authorization flow. +// See: https://tools.ietf.org/html/rfc8628#section-3.1 +func (c *DeviceAuth) AuthorizeDevice(ctx context.Context) (*codersdk.ExternalAuthDevice, error) { + if c.CodeURL == "" { + return nil, xerrors.New("oauth2: device code URL not set") + } + codeURL, err := c.formatDeviceCodeURL() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, codeURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var r struct { + codersdk.ExternalAuthDevice + ErrorDescription string `json:"error_description"` + } + err = json.NewDecoder(resp.Body).Decode(&r) + if err != nil { + return nil, err + } + if r.ErrorDescription != "" { + return nil, xerrors.New(r.ErrorDescription) + } + return &r.ExternalAuthDevice, nil +} + +type ExchangeDeviceCodeResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +// ExchangeDeviceCode exchanges a device code for an access token. +// The boolean returned indicates whether the device code is still pending +// and the caller should try again. +func (c *DeviceAuth) ExchangeDeviceCode(ctx context.Context, deviceCode string) (*oauth2.Token, error) { + if c.TokenURL == "" { + return nil, xerrors.New("oauth2: token URL not set") + } + tokenURL, err := c.formatDeviceTokenURL(deviceCode) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, codersdk.ReadBodyAsError(resp) + } + var body ExchangeDeviceCodeResponse + err = json.NewDecoder(resp.Body).Decode(&body) + if err != nil { + return nil, err + } + if body.Error != "" { + return nil, xerrors.New(body.Error) + } + return &oauth2.Token{ + AccessToken: body.AccessToken, + RefreshToken: body.RefreshToken, + Expiry: dbtime.Now().Add(time.Duration(body.ExpiresIn) * time.Second), + }, nil +} + +func (c *DeviceAuth) formatDeviceTokenURL(deviceCode string) (string, error) { + tok, err := url.Parse(c.TokenURL) + if err != nil { + return "", err + } + tok.RawQuery = url.Values{ + "client_id": {c.ClientID}, + "device_code": {deviceCode}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }.Encode() + return tok.String(), nil +} + +func (c *DeviceAuth) formatDeviceCodeURL() (string, error) { + cod, err := url.Parse(c.CodeURL) + if err != nil { + return "", err + } + cod.RawQuery = url.Values{ + "client_id": {c.ClientID}, + "scope": c.Scopes, + }.Encode() + return cod.String(), nil +} + // ConvertConfig converts the SDK configuration entry format // to the parsed and ready-to-consume in coderd provider type. -func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Config, error) { +func ConvertConfig(entries []codersdk.ExternalAuthConfig, accessURL *url.URL) ([]*Config, error) { ids := map[string]struct{}{} configs := []*Config{} for _, entry := range entries { - var typ codersdk.ExternalAuthProvider - switch codersdk.ExternalAuthProvider(entry.Type) { - case codersdk.ExternalAuthProviderAzureDevops: - typ = codersdk.ExternalAuthProviderAzureDevops - case codersdk.ExternalAuthProviderBitBucket: - typ = codersdk.ExternalAuthProviderBitBucket - case codersdk.ExternalAuthProviderGitHub: - typ = codersdk.ExternalAuthProviderGitHub - case codersdk.ExternalAuthProviderGitLab: - typ = codersdk.ExternalAuthProviderGitLab - default: - return nil, xerrors.Errorf("unknown git provider type: %q", entry.Type) - } - if entry.ID == "" { - // Default to the type. - entry.ID = string(typ) - } - if valid := httpapi.NameValid(entry.ID); valid != nil { + entry := entry + + // Applies defaults to the config entry. + // This allows users to very simply state that they type is "GitHub", + // apply their client secret and ID, and have the UI appear nicely. + applyDefaultsToConfig(&entry) + + valid := httpapi.NameValid(entry.ID) + if valid != nil { return nil, xerrors.Errorf("external auth provider %q doesn't have a valid id: %w", entry.ID, valid) } + if entry.ClientID == "" { + return nil, xerrors.Errorf("%q external auth provider: client_id must be provided", entry.ID) + } + if entry.ClientSecret == "" { + return nil, xerrors.Errorf("%q external auth provider: client_secret must be provided", entry.ID) + } _, exists := ids[entry.ID] if exists { - if entry.ID == string(typ) { - return nil, xerrors.Errorf("multiple %s external auth providers provided. you must specify a unique id for each", typ) + if entry.ID == entry.Type { + return nil, xerrors.Errorf("multiple %s external auth providers provided. you must specify a unique id for each", entry.Type) } - return nil, xerrors.Errorf("multiple git providers exist with the id %q. specify a unique id for each", entry.ID) + return nil, xerrors.Errorf("multiple external auth providers exist with the id %q. specify a unique id for each", entry.ID) } ids[entry.ID] = struct{}{} - if entry.ClientID == "" { - return nil, xerrors.Errorf("%q external auth provider: client_id must be provided", entry.ID) - } - authRedirect, err := accessURL.Parse(fmt.Sprintf("/externalauth/%s/callback", entry.ID)) + authRedirect, err := accessURL.Parse(fmt.Sprintf("/external-auth/%s/callback", entry.ID)) if err != nil { - return nil, xerrors.Errorf("parse externalauth callback url: %w", err) + return nil, xerrors.Errorf("parse external auth callback url: %w", err) } - regex := regex[typ] + + var regex *regexp.Regexp if entry.Regex != "" { regex, err = regexp.Compile(entry.Regex) if err != nil { @@ -299,30 +412,17 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con oc := &oauth2.Config{ ClientID: entry.ClientID, ClientSecret: entry.ClientSecret, - Endpoint: endpoint[typ], - RedirectURL: authRedirect.String(), - Scopes: scope[typ], - } - - if entry.AuthURL != "" { - oc.Endpoint.AuthURL = entry.AuthURL - } - if entry.TokenURL != "" { - oc.Endpoint.TokenURL = entry.TokenURL - } - if entry.Scopes != nil && len(entry.Scopes) > 0 { - oc.Scopes = entry.Scopes - } - if entry.ValidateURL == "" { - entry.ValidateURL = validateURL[typ] - } - if entry.AppInstallationsURL == "" { - entry.AppInstallationsURL = appInstallationsURL[typ] + Endpoint: oauth2.Endpoint{ + AuthURL: entry.AuthURL, + TokenURL: entry.TokenURL, + }, + RedirectURL: authRedirect.String(), + Scopes: entry.Scopes, } var oauthConfig OAuth2Config = oc // Azure DevOps uses JWT token authentication! - if typ == codersdk.ExternalAuthProviderAzureDevops { + if entry.Type == string(codersdk.EnhancedExternalAuthProviderAzureDevops) { oauthConfig = &jwtConfig{oc} } @@ -330,17 +430,16 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con OAuth2Config: oauthConfig, ID: entry.ID, Regex: regex, - Type: typ, + Type: entry.Type, NoRefresh: entry.NoRefresh, ValidateURL: entry.ValidateURL, AppInstallationsURL: entry.AppInstallationsURL, AppInstallURL: entry.AppInstallURL, + DisplayName: entry.DisplayName, + DisplayIcon: entry.DisplayIcon, } if entry.DeviceFlow { - if entry.DeviceCodeURL == "" { - entry.DeviceCodeURL = deviceAuthURL[typ] - } if entry.DeviceCodeURL == "" { return nil, xerrors.Errorf("external auth provider %q: device auth url must be provided", entry.ID) } @@ -356,3 +455,123 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con } return configs, nil } + +// applyDefaultsToConfig applies defaults to the config entry. +func applyDefaultsToConfig(config *codersdk.ExternalAuthConfig) { + defaults := defaults[codersdk.EnhancedExternalAuthProvider(config.Type)] + if config.AuthURL == "" { + config.AuthURL = defaults.AuthURL + } + if config.TokenURL == "" { + config.TokenURL = defaults.TokenURL + } + if config.ValidateURL == "" { + config.ValidateURL = defaults.ValidateURL + } + if config.AppInstallURL == "" { + config.AppInstallURL = defaults.AppInstallURL + } + if config.AppInstallationsURL == "" { + config.AppInstallationsURL = defaults.AppInstallationsURL + } + if config.Regex == "" { + config.Regex = defaults.Regex + } + if config.Scopes == nil || len(config.Scopes) == 0 { + config.Scopes = defaults.Scopes + } + if config.DeviceCodeURL == "" { + config.DeviceCodeURL = defaults.DeviceCodeURL + } + if config.DisplayName == "" { + config.DisplayName = defaults.DisplayName + } + if config.DisplayIcon == "" { + config.DisplayIcon = defaults.DisplayIcon + } + + // Apply defaults if it's still empty... + if config.ID == "" { + config.ID = config.Type + } + if config.DisplayName == "" { + config.DisplayName = config.Type + } + if config.DisplayIcon == "" { + // This is a key emoji. + config.DisplayIcon = "/emojis/1f511.png" + } +} + +var defaults = map[codersdk.EnhancedExternalAuthProvider]codersdk.ExternalAuthConfig{ + codersdk.EnhancedExternalAuthProviderAzureDevops: { + AuthURL: "https://app.vssps.visualstudio.com/oauth2/authorize", + TokenURL: "https://app.vssps.visualstudio.com/oauth2/token", + DisplayName: "Azure DevOps", + DisplayIcon: "/icon/azure-devops.svg", + Regex: `^(https?://)?dev\.azure\.com(/.*)?$`, + Scopes: []string{"vso.code_write"}, + }, + codersdk.EnhancedExternalAuthProviderBitBucket: { + AuthURL: "https://bitbucket.org/site/oauth2/authorize", + TokenURL: "https://bitbucket.org/site/oauth2/access_token", + ValidateURL: "https://api.bitbucket.org/2.0/user", + DisplayName: "BitBucket", + DisplayIcon: "/icon/bitbucket.svg", + Regex: `^(https?://)?bitbucket\.org(/.*)?$`, + Scopes: []string{"account", "repository:write"}, + }, + codersdk.EnhancedExternalAuthProviderGitLab: { + AuthURL: "https://gitlab.com/oauth/authorize", + TokenURL: "https://gitlab.com/oauth/token", + ValidateURL: "https://gitlab.com/oauth/token/info", + DisplayName: "GitLab", + DisplayIcon: "/icon/gitlab.svg", + Regex: `^(https?://)?gitlab\.com(/.*)?$`, + Scopes: []string{"write_repository"}, + }, + codersdk.EnhancedExternalAuthProviderGitHub: { + AuthURL: xgithub.Endpoint.AuthURL, + TokenURL: xgithub.Endpoint.TokenURL, + ValidateURL: "https://api.github.com/user", + DisplayName: "GitHub", + DisplayIcon: "/icon/github.svg", + Regex: `^(https?://)?github\.com(/.*)?$`, + // "workflow" is required for managing GitHub Actions in a repository. + Scopes: []string{"repo", "workflow"}, + DeviceCodeURL: "https://github.com/login/device/code", + AppInstallationsURL: "https://api.github.com/user/installations", + }, +} + +// jwtConfig is a new OAuth2 config that uses a custom +// assertion method that works with Azure Devops. See: +// https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=azure-devops +type jwtConfig struct { + *oauth2.Config +} + +func (c *jwtConfig) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + return c.Config.AuthCodeURL(state, append(opts, oauth2.SetAuthURLParam("response_type", "Assertion"))...) +} + +func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + v := url.Values{ + "client_assertion_type": {}, + "client_assertion": {c.ClientSecret}, + "assertion": {code}, + "grant_type": {}, + } + if c.RedirectURL != "" { + v.Set("redirect_uri", c.RedirectURL) + } + return c.Config.Exchange(ctx, code, + append(opts, + oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), + oauth2.SetAuthURLParam("client_assertion", c.ClientSecret), + oauth2.SetAuthURLParam("assertion", code), + oauth2.SetAuthURLParam("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + oauth2.SetAuthURLParam("code", ""), + )..., + ) +} diff --git a/coderd/externalauth/config_test.go b/coderd/externalauth/externalauth_test.go similarity index 91% rename from coderd/externalauth/config_test.go rename to coderd/externalauth/externalauth_test.go index 4bd9e0b162..418d143d16 100644 --- a/coderd/externalauth/config_test.go +++ b/coderd/externalauth/externalauth_test.go @@ -176,7 +176,7 @@ func TestRefreshToken(t *testing.T) { }), }, GitConfigOpt: func(cfg *externalauth.Config) { - cfg.Type = codersdk.ExternalAuthProviderGitHub + cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String() }, }) @@ -206,7 +206,7 @@ func TestRefreshToken(t *testing.T) { }), }, GitConfigOpt: func(cfg *externalauth.Config) { - cfg.Type = codersdk.ExternalAuthProviderGitHub + cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String() }, }) @@ -237,7 +237,7 @@ func TestRefreshToken(t *testing.T) { }), }, GitConfigOpt: func(cfg *externalauth.Config) { - cfg.Type = codersdk.ExternalAuthProviderGitHub + cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String() }, DB: db, }) @@ -266,42 +266,38 @@ func TestConvertYAML(t *testing.T) { t.Parallel() for _, tc := range []struct { Name string - Input []codersdk.GitAuthConfig + Input []codersdk.ExternalAuthConfig Output []*externalauth.Config Error string }{{ - Name: "InvalidType", - Input: []codersdk.GitAuthConfig{{ - Type: "moo", - }}, - Error: "unknown git provider type", - }, { Name: "InvalidID", - Input: []codersdk.GitAuthConfig{{ - Type: string(codersdk.ExternalAuthProviderGitHub), + Input: []codersdk.ExternalAuthConfig{{ + Type: string(codersdk.EnhancedExternalAuthProviderGitHub), ID: "$hi$", }}, Error: "doesn't have a valid id", }, { Name: "NoClientID", - Input: []codersdk.GitAuthConfig{{ - Type: string(codersdk.ExternalAuthProviderGitHub), + Input: []codersdk.ExternalAuthConfig{{ + Type: string(codersdk.EnhancedExternalAuthProviderGitHub), }}, Error: "client_id must be provided", }, { Name: "DuplicateType", - Input: []codersdk.GitAuthConfig{{ - Type: string(codersdk.ExternalAuthProviderGitHub), + Input: []codersdk.ExternalAuthConfig{{ + Type: string(codersdk.EnhancedExternalAuthProviderGitHub), ClientID: "example", ClientSecret: "example", }, { - Type: string(codersdk.ExternalAuthProviderGitHub), + Type: string(codersdk.EnhancedExternalAuthProviderGitHub), + ClientID: "example-2", + ClientSecret: "example-2", }}, Error: "multiple github external auth providers provided", }, { Name: "InvalidRegex", - Input: []codersdk.GitAuthConfig{{ - Type: string(codersdk.ExternalAuthProviderGitHub), + Input: []codersdk.ExternalAuthConfig{{ + Type: string(codersdk.EnhancedExternalAuthProviderGitHub), ClientID: "example", ClientSecret: "example", Regex: `\K`, @@ -309,8 +305,8 @@ func TestConvertYAML(t *testing.T) { Error: "compile regex for external auth provider", }, { Name: "NoDeviceURL", - Input: []codersdk.GitAuthConfig{{ - Type: string(codersdk.ExternalAuthProviderGitLab), + Input: []codersdk.ExternalAuthConfig{{ + Type: string(codersdk.EnhancedExternalAuthProviderGitLab), ClientID: "example", ClientSecret: "example", DeviceFlow: true, @@ -332,8 +328,8 @@ func TestConvertYAML(t *testing.T) { t.Run("CustomScopesAndEndpoint", func(t *testing.T) { t.Parallel() - config, err := externalauth.ConvertConfig([]codersdk.GitAuthConfig{{ - Type: string(codersdk.ExternalAuthProviderGitLab), + config, err := externalauth.ConvertConfig([]codersdk.ExternalAuthConfig{{ + Type: string(codersdk.EnhancedExternalAuthProviderGitLab), ClientID: "id", ClientSecret: "secret", AuthURL: "https://auth.com", @@ -341,7 +337,7 @@ func TestConvertYAML(t *testing.T) { Scopes: []string{"read"}, }}, &url.URL{}) require.NoError(t, err) - require.Equal(t, "https://auth.com?client_id=id&redirect_uri=%2Fexternalauth%2Fgitlab%2Fcallback&response_type=code&scope=read", config[0].AuthCodeURL("")) + require.Equal(t, "https://auth.com?client_id=id&redirect_uri=%2Fexternal-auth%2Fgitlab%2Fcallback&response_type=code&scope=read", config[0].AuthCodeURL("")) }) } diff --git a/coderd/externalauth/oauth.go b/coderd/externalauth/oauth.go deleted file mode 100644 index 0f679e8fe0..0000000000 --- a/coderd/externalauth/oauth.go +++ /dev/null @@ -1,212 +0,0 @@ -package externalauth - -import ( - "context" - "encoding/json" - "net/http" - "net/url" - "regexp" - "time" - - "golang.org/x/oauth2" - "golang.org/x/oauth2/github" - "golang.org/x/xerrors" - - "github.com/coder/coder/v2/coderd/database/dbtime" - "github.com/coder/coder/v2/codersdk" -) - -// endpoint contains default SaaS URLs for each Git provider. -var endpoint = map[codersdk.ExternalAuthProvider]oauth2.Endpoint{ - codersdk.ExternalAuthProviderAzureDevops: { - AuthURL: "https://app.vssps.visualstudio.com/oauth2/authorize", - TokenURL: "https://app.vssps.visualstudio.com/oauth2/token", - }, - codersdk.ExternalAuthProviderBitBucket: { - AuthURL: "https://bitbucket.org/site/oauth2/authorize", - TokenURL: "https://bitbucket.org/site/oauth2/access_token", - }, - codersdk.ExternalAuthProviderGitLab: { - AuthURL: "https://gitlab.com/oauth/authorize", - TokenURL: "https://gitlab.com/oauth/token", - }, - codersdk.ExternalAuthProviderGitHub: github.Endpoint, -} - -// validateURL contains defaults for each provider. -var validateURL = map[codersdk.ExternalAuthProvider]string{ - codersdk.ExternalAuthProviderGitHub: "https://api.github.com/user", - codersdk.ExternalAuthProviderGitLab: "https://gitlab.com/oauth/token/info", - codersdk.ExternalAuthProviderBitBucket: "https://api.bitbucket.org/2.0/user", -} - -var deviceAuthURL = map[codersdk.ExternalAuthProvider]string{ - codersdk.ExternalAuthProviderGitHub: "https://github.com/login/device/code", -} - -var appInstallationsURL = map[codersdk.ExternalAuthProvider]string{ - codersdk.ExternalAuthProviderGitHub: "https://api.github.com/user/installations", -} - -// scope contains defaults for each Git provider. -var scope = map[codersdk.ExternalAuthProvider][]string{ - codersdk.ExternalAuthProviderAzureDevops: {"vso.code_write"}, - codersdk.ExternalAuthProviderBitBucket: {"account", "repository:write"}, - codersdk.ExternalAuthProviderGitLab: {"write_repository"}, - // "workflow" is required for managing GitHub Actions in a repository. - codersdk.ExternalAuthProviderGitHub: {"repo", "workflow"}, -} - -// regex provides defaults for each Git provider to match their SaaS host URL. -// This is configurable by each provider. -var regex = map[codersdk.ExternalAuthProvider]*regexp.Regexp{ - codersdk.ExternalAuthProviderAzureDevops: regexp.MustCompile(`^(https?://)?dev\.azure\.com(/.*)?$`), - codersdk.ExternalAuthProviderBitBucket: regexp.MustCompile(`^(https?://)?bitbucket\.org(/.*)?$`), - codersdk.ExternalAuthProviderGitLab: regexp.MustCompile(`^(https?://)?gitlab\.com(/.*)?$`), - codersdk.ExternalAuthProviderGitHub: regexp.MustCompile(`^(https?://)?github\.com(/.*)?$`), -} - -// jwtConfig is a new OAuth2 config that uses a custom -// assertion method that works with Azure Devops. See: -// https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=azure-devops -type jwtConfig struct { - *oauth2.Config -} - -func (c *jwtConfig) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { - return c.Config.AuthCodeURL(state, append(opts, oauth2.SetAuthURLParam("response_type", "Assertion"))...) -} - -func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { - v := url.Values{ - "client_assertion_type": {}, - "client_assertion": {c.ClientSecret}, - "assertion": {code}, - "grant_type": {}, - } - if c.RedirectURL != "" { - v.Set("redirect_uri", c.RedirectURL) - } - return c.Config.Exchange(ctx, code, - append(opts, - oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), - oauth2.SetAuthURLParam("client_assertion", c.ClientSecret), - oauth2.SetAuthURLParam("assertion", code), - oauth2.SetAuthURLParam("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), - oauth2.SetAuthURLParam("code", ""), - )..., - ) -} - -type DeviceAuth struct { - ClientID string - TokenURL string - Scopes []string - CodeURL string -} - -// AuthorizeDevice begins the device authorization flow. -// See: https://tools.ietf.org/html/rfc8628#section-3.1 -func (c *DeviceAuth) AuthorizeDevice(ctx context.Context) (*codersdk.ExternalAuthDevice, error) { - if c.CodeURL == "" { - return nil, xerrors.New("oauth2: device code URL not set") - } - codeURL, err := c.formatDeviceCodeURL() - if err != nil { - return nil, err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, codeURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", "application/json") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - var r struct { - codersdk.ExternalAuthDevice - ErrorDescription string `json:"error_description"` - } - err = json.NewDecoder(resp.Body).Decode(&r) - if err != nil { - return nil, err - } - if r.ErrorDescription != "" { - return nil, xerrors.New(r.ErrorDescription) - } - return &r.ExternalAuthDevice, nil -} - -type ExchangeDeviceCodeResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - Error string `json:"error"` - ErrorDescription string `json:"error_description"` -} - -// ExchangeDeviceCode exchanges a device code for an access token. -// The boolean returned indicates whether the device code is still pending -// and the caller should try again. -func (c *DeviceAuth) ExchangeDeviceCode(ctx context.Context, deviceCode string) (*oauth2.Token, error) { - if c.TokenURL == "" { - return nil, xerrors.New("oauth2: token URL not set") - } - tokenURL, err := c.formatDeviceTokenURL(deviceCode) - if err != nil { - return nil, err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", "application/json") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, codersdk.ReadBodyAsError(resp) - } - var body ExchangeDeviceCodeResponse - err = json.NewDecoder(resp.Body).Decode(&body) - if err != nil { - return nil, err - } - if body.Error != "" { - return nil, xerrors.New(body.Error) - } - return &oauth2.Token{ - AccessToken: body.AccessToken, - RefreshToken: body.RefreshToken, - Expiry: dbtime.Now().Add(time.Duration(body.ExpiresIn) * time.Second), - }, nil -} - -func (c *DeviceAuth) formatDeviceTokenURL(deviceCode string) (string, error) { - tok, err := url.Parse(c.TokenURL) - if err != nil { - return "", err - } - tok.RawQuery = url.Values{ - "client_id": {c.ClientID}, - "device_code": {deviceCode}, - "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, - }.Encode() - return tok.String(), nil -} - -func (c *DeviceAuth) formatDeviceCodeURL() (string, error) { - cod, err := url.Parse(c.CodeURL) - if err != nil { - return "", err - } - cod.RawQuery = url.Values{ - "client_id": {c.ClientID}, - "scope": c.Scopes, - }.Encode() - return cod.String(), nil -} diff --git a/coderd/externalauth_test.go b/coderd/externalauth_test.go index b5090b6058..e601d86785 100644 --- a/coderd/externalauth_test.go +++ b/coderd/externalauth_test.go @@ -34,7 +34,7 @@ func TestExternalAuthByID(t *testing.T) { ExternalAuthConfigs: []*externalauth.Config{{ ID: "test", OAuth2Config: &testutil.OAuth2Config{}, - Type: codersdk.ExternalAuthProviderGitHub, + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), }}, }) coderdtest.CreateFirstUser(t, client) @@ -51,7 +51,7 @@ func TestExternalAuthByID(t *testing.T) { ID: "test", OAuth2Config: &testutil.OAuth2Config{}, // AzureDevops doesn't have a user endpoint! - Type: codersdk.ExternalAuthProviderAzureDevops, + Type: codersdk.EnhancedExternalAuthProviderAzureDevops.String(), }}, }) coderdtest.CreateFirstUser(t, client) @@ -75,7 +75,7 @@ func TestExternalAuthByID(t *testing.T) { ID: "test", ValidateURL: validateSrv.URL, OAuth2Config: &testutil.OAuth2Config{}, - Type: codersdk.ExternalAuthProviderGitHub, + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), }}, }) coderdtest.CreateFirstUser(t, client) @@ -116,7 +116,7 @@ func TestExternalAuthByID(t *testing.T) { ValidateURL: srv.URL + "/user", AppInstallationsURL: srv.URL + "/installs", OAuth2Config: &testutil.OAuth2Config{}, - Type: codersdk.ExternalAuthProviderGitHub, + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), }}, }) coderdtest.CreateFirstUser(t, client) @@ -249,7 +249,7 @@ func TestGitAuthCallback(t *testing.T) { OAuth2Config: &testutil.OAuth2Config{}, ID: "github", Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.ExternalAuthProviderGitHub, + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), }}, }) user := coderdtest.CreateFirstUser(t, client) @@ -268,7 +268,7 @@ func TestGitAuthCallback(t *testing.T) { agentClient.SetSessionToken(authToken) token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) require.NoError(t, err) - require.True(t, strings.HasSuffix(token.URL, fmt.Sprintf("/externalauth/%s", "github"))) + require.True(t, strings.HasSuffix(token.URL, fmt.Sprintf("/external-auth/%s", "github")), token.URL) }) t.Run("UnauthorizedCallback", func(t *testing.T) { t.Parallel() @@ -278,7 +278,7 @@ func TestGitAuthCallback(t *testing.T) { OAuth2Config: &testutil.OAuth2Config{}, ID: "github", Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.ExternalAuthProviderGitHub, + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), }}, }) resp := coderdtest.RequestExternalAuthCallback(t, "github", client) @@ -292,7 +292,7 @@ func TestGitAuthCallback(t *testing.T) { OAuth2Config: &testutil.OAuth2Config{}, ID: "github", Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.ExternalAuthProviderGitHub, + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), }}, }) _ = coderdtest.CreateFirstUser(t, client) @@ -300,7 +300,7 @@ func TestGitAuthCallback(t *testing.T) { require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) location, err := resp.Location() require.NoError(t, err) - require.Equal(t, "/externalauth/github", location.Path) + require.Equal(t, "/external-auth/github", location.Path) // Callback again to simulate updating the token. resp = coderdtest.RequestExternalAuthCallback(t, "github", client) @@ -319,7 +319,7 @@ func TestGitAuthCallback(t *testing.T) { OAuth2Config: &testutil.OAuth2Config{}, ID: "github", Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.ExternalAuthProviderGitHub, + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), }}, }) user := coderdtest.CreateFirstUser(t, client) @@ -376,7 +376,7 @@ func TestGitAuthCallback(t *testing.T) { }, ID: "github", Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.ExternalAuthProviderGitHub, + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), NoRefresh: true, }}, }) @@ -420,7 +420,7 @@ func TestGitAuthCallback(t *testing.T) { OAuth2Config: &testutil.OAuth2Config{}, ID: "github", Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.ExternalAuthProviderGitHub, + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), }}, }) user := coderdtest.CreateFirstUser(t, client) diff --git a/coderd/httpmw/patternmatcher/routepatterns_test.go b/coderd/httpmw/patternmatcher/routepatterns_test.go index 880fa88e71..58d914d231 100644 --- a/coderd/httpmw/patternmatcher/routepatterns_test.go +++ b/coderd/httpmw/patternmatcher/routepatterns_test.go @@ -65,9 +65,9 @@ func Test_RoutePatterns(t *testing.T) { "/api/**", "/@*/*/apps/**", "/%40*/*/apps/**", - "/externalauth/*/callback", + "/external-auth/*/callback", }, - output: "^(/api/?|/api/.+/?|/@[^/]+/[^/]+/apps/.+/?|/%40[^/]+/[^/]+/apps/.+/?|/externalauth/[^/]+/callback/?)$", + output: "^(/api/?|/api/.+/?|/@[^/]+/[^/]+/apps/.+/?|/%40[^/]+/[^/]+/apps/.+/?|/external-auth/[^/]+/callback/?)$", }, { name: "Slash", diff --git a/coderd/templateversions.go b/coderd/templateversions.go index cbc9bb0605..fbde7e28e8 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -280,7 +280,7 @@ func (api *API) templateVersionRichParameters(rw http.ResponseWriter, r *http.Re // @Tags Templates // @Param templateversion path string true "Template version ID" format(uuid) // @Success 200 {array} codersdk.TemplateVersionExternalAuth -// @Router /templateversions/{templateversion}/externalauth [get] +// @Router /templateversions/{templateversion}/external-auth [get] func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() var ( @@ -307,7 +307,7 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ } // This is the URL that will redirect the user with a state token. - redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/externalauth/%s", config.ID)) + redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/external-auth/%s", config.ID)) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to parse access URL.", @@ -320,6 +320,8 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ ID: config.ID, Type: config.Type, AuthenticateURL: redirectURL.String(), + DisplayName: config.DisplayName, + DisplayIcon: config.DisplayIcon, } authLink, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{ diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index 7f9c7f54dc..cd3fd3a151 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -342,7 +342,7 @@ func TestTemplateVersionsExternalAuth(t *testing.T) { OAuth2Config: &testutil.OAuth2Config{}, ID: "github", Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.ExternalAuthProviderGitHub, + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), }}, }) user := coderdtest.CreateFirstUser(t, client) diff --git a/coderd/tracing/httpmw.go b/coderd/tracing/httpmw.go index 994c16f41a..26b57a1d22 100644 --- a/coderd/tracing/httpmw.go +++ b/coderd/tracing/httpmw.go @@ -26,7 +26,7 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han "/api/**", "/@*/*/apps/**", "/%40*/*/apps/**", - "/externalauth/*/callback", + "/external-auth/*/callback", }.MustCompile() var tracer trace.Tracer diff --git a/coderd/tracing/httpmw_test.go b/coderd/tracing/httpmw_test.go index ca759513ec..1ee46ddf2e 100644 --- a/coderd/tracing/httpmw_test.go +++ b/coderd/tracing/httpmw_test.go @@ -59,7 +59,7 @@ func Test_Middleware(t *testing.T) { {"/%40hi/hi/apps/hi", true}, {"/%40hi/hi/apps/hi/hi", true}, {"/%40hi/hi/apps/hi/hi", true}, - {"/externalauth/hi/callback", true}, + {"/external-auth/hi/callback", true}, // Other routes that should not be collected. {"/index.html", false}, diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index b7b258b4f8..9a39f5ac0f 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -2201,6 +2201,13 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) }) return } + enhancedType := codersdk.EnhancedExternalAuthProvider(externalAuthConfig.Type) + if !enhancedType.Git() { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "External auth provider does not support git.", + }) + return + } workspaceAgent := httpmw.WorkspaceAgent(r) // We must get the workspace to get the owner ID! resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID) @@ -2272,13 +2279,13 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) if !valid { continue } - httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken)) + httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(enhancedType, externalAuthLink.OAuthAccessToken)) return } } // This is the URL that will redirect the user with a state token. - redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/externalauth/%s", externalAuthConfig.ID)) + redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/external-auth/%s", externalAuthConfig.ID)) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to parse access URL.", @@ -2320,20 +2327,20 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) }) return } - httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken)) + httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(enhancedType, externalAuthLink.OAuthAccessToken)) } // Provider types have different username/password formats. -func formatGitAuthAccessToken(typ codersdk.ExternalAuthProvider, token string) agentsdk.GitAuthResponse { +func formatGitAuthAccessToken(typ codersdk.EnhancedExternalAuthProvider, token string) agentsdk.GitAuthResponse { var resp agentsdk.GitAuthResponse switch typ { - case codersdk.ExternalAuthProviderGitLab: + case codersdk.EnhancedExternalAuthProviderGitLab: // https://stackoverflow.com/questions/25409700/using-gitlab-token-to-clone-without-authentication resp = agentsdk.GitAuthResponse{ Username: "oauth2", Password: token, } - case codersdk.ExternalAuthProviderBitBucket: + case codersdk.EnhancedExternalAuthProviderBitBucket: // https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/#Cloning-a-repository-with-an-access-token resp = agentsdk.GitAuthResponse{ Username: "x-token-auth", diff --git a/codersdk/deployment.go b/codersdk/deployment.go index be629236d3..b20d052eb4 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -134,52 +134,52 @@ type DeploymentValues struct { DocsURL clibase.URL `json:"docs_url,omitempty"` RedirectToAccessURL clibase.Bool `json:"redirect_to_access_url,omitempty"` // HTTPAddress is a string because it may be set to zero to disable. - HTTPAddress clibase.String `json:"http_address,omitempty" typescript:",notnull"` - AutobuildPollInterval clibase.Duration `json:"autobuild_poll_interval,omitempty"` - JobHangDetectorInterval clibase.Duration `json:"job_hang_detector_interval,omitempty"` - DERP DERP `json:"derp,omitempty" typescript:",notnull"` - Prometheus PrometheusConfig `json:"prometheus,omitempty" typescript:",notnull"` - Pprof PprofConfig `json:"pprof,omitempty" typescript:",notnull"` - ProxyTrustedHeaders clibase.StringArray `json:"proxy_trusted_headers,omitempty" typescript:",notnull"` - ProxyTrustedOrigins clibase.StringArray `json:"proxy_trusted_origins,omitempty" typescript:",notnull"` - CacheDir clibase.String `json:"cache_directory,omitempty" typescript:",notnull"` - InMemoryDatabase clibase.Bool `json:"in_memory_database,omitempty" typescript:",notnull"` - PostgresURL clibase.String `json:"pg_connection_url,omitempty" typescript:",notnull"` - OAuth2 OAuth2Config `json:"oauth2,omitempty" typescript:",notnull"` - OIDC OIDCConfig `json:"oidc,omitempty" typescript:",notnull"` - Telemetry TelemetryConfig `json:"telemetry,omitempty" typescript:",notnull"` - TLS TLSConfig `json:"tls,omitempty" typescript:",notnull"` - Trace TraceConfig `json:"trace,omitempty" typescript:",notnull"` - SecureAuthCookie clibase.Bool `json:"secure_auth_cookie,omitempty" typescript:",notnull"` - StrictTransportSecurity clibase.Int64 `json:"strict_transport_security,omitempty" typescript:",notnull"` - StrictTransportSecurityOptions clibase.StringArray `json:"strict_transport_security_options,omitempty" typescript:",notnull"` - SSHKeygenAlgorithm clibase.String `json:"ssh_keygen_algorithm,omitempty" typescript:",notnull"` - MetricsCacheRefreshInterval clibase.Duration `json:"metrics_cache_refresh_interval,omitempty" typescript:",notnull"` - AgentStatRefreshInterval clibase.Duration `json:"agent_stat_refresh_interval,omitempty" typescript:",notnull"` - AgentFallbackTroubleshootingURL clibase.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"` - BrowserOnly clibase.Bool `json:"browser_only,omitempty" typescript:",notnull"` - SCIMAPIKey clibase.String `json:"scim_api_key,omitempty" typescript:",notnull"` - ExternalTokenEncryptionKeys clibase.StringArray `json:"external_token_encryption_keys,omitempty" typescript:",notnull"` - Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"` - RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"` - Experiments clibase.StringArray `json:"experiments,omitempty" typescript:",notnull"` - UpdateCheck clibase.Bool `json:"update_check,omitempty" typescript:",notnull"` - MaxTokenLifetime clibase.Duration `json:"max_token_lifetime,omitempty" typescript:",notnull"` - Swagger SwaggerConfig `json:"swagger,omitempty" typescript:",notnull"` - Logging LoggingConfig `json:"logging,omitempty" typescript:",notnull"` - Dangerous DangerousConfig `json:"dangerous,omitempty" typescript:",notnull"` - DisablePathApps clibase.Bool `json:"disable_path_apps,omitempty" typescript:",notnull"` - SessionDuration clibase.Duration `json:"max_session_expiry,omitempty" typescript:",notnull"` - DisableSessionExpiryRefresh clibase.Bool `json:"disable_session_expiry_refresh,omitempty" typescript:",notnull"` - DisablePasswordAuth clibase.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"` - Support SupportConfig `json:"support,omitempty" typescript:",notnull"` - GitAuthProviders clibase.Struct[[]GitAuthConfig] `json:"git_auth,omitempty" typescript:",notnull"` - SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` - WgtunnelHost clibase.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` - DisableOwnerWorkspaceExec clibase.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` - ProxyHealthStatusInterval clibase.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` - EnableTerraformDebugMode clibase.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"` - UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` + HTTPAddress clibase.String `json:"http_address,omitempty" typescript:",notnull"` + AutobuildPollInterval clibase.Duration `json:"autobuild_poll_interval,omitempty"` + JobHangDetectorInterval clibase.Duration `json:"job_hang_detector_interval,omitempty"` + DERP DERP `json:"derp,omitempty" typescript:",notnull"` + Prometheus PrometheusConfig `json:"prometheus,omitempty" typescript:",notnull"` + Pprof PprofConfig `json:"pprof,omitempty" typescript:",notnull"` + ProxyTrustedHeaders clibase.StringArray `json:"proxy_trusted_headers,omitempty" typescript:",notnull"` + ProxyTrustedOrigins clibase.StringArray `json:"proxy_trusted_origins,omitempty" typescript:",notnull"` + CacheDir clibase.String `json:"cache_directory,omitempty" typescript:",notnull"` + InMemoryDatabase clibase.Bool `json:"in_memory_database,omitempty" typescript:",notnull"` + PostgresURL clibase.String `json:"pg_connection_url,omitempty" typescript:",notnull"` + OAuth2 OAuth2Config `json:"oauth2,omitempty" typescript:",notnull"` + OIDC OIDCConfig `json:"oidc,omitempty" typescript:",notnull"` + Telemetry TelemetryConfig `json:"telemetry,omitempty" typescript:",notnull"` + TLS TLSConfig `json:"tls,omitempty" typescript:",notnull"` + Trace TraceConfig `json:"trace,omitempty" typescript:",notnull"` + SecureAuthCookie clibase.Bool `json:"secure_auth_cookie,omitempty" typescript:",notnull"` + StrictTransportSecurity clibase.Int64 `json:"strict_transport_security,omitempty" typescript:",notnull"` + StrictTransportSecurityOptions clibase.StringArray `json:"strict_transport_security_options,omitempty" typescript:",notnull"` + SSHKeygenAlgorithm clibase.String `json:"ssh_keygen_algorithm,omitempty" typescript:",notnull"` + MetricsCacheRefreshInterval clibase.Duration `json:"metrics_cache_refresh_interval,omitempty" typescript:",notnull"` + AgentStatRefreshInterval clibase.Duration `json:"agent_stat_refresh_interval,omitempty" typescript:",notnull"` + AgentFallbackTroubleshootingURL clibase.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"` + BrowserOnly clibase.Bool `json:"browser_only,omitempty" typescript:",notnull"` + SCIMAPIKey clibase.String `json:"scim_api_key,omitempty" typescript:",notnull"` + ExternalTokenEncryptionKeys clibase.StringArray `json:"external_token_encryption_keys,omitempty" typescript:",notnull"` + Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"` + RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"` + Experiments clibase.StringArray `json:"experiments,omitempty" typescript:",notnull"` + UpdateCheck clibase.Bool `json:"update_check,omitempty" typescript:",notnull"` + MaxTokenLifetime clibase.Duration `json:"max_token_lifetime,omitempty" typescript:",notnull"` + Swagger SwaggerConfig `json:"swagger,omitempty" typescript:",notnull"` + Logging LoggingConfig `json:"logging,omitempty" typescript:",notnull"` + Dangerous DangerousConfig `json:"dangerous,omitempty" typescript:",notnull"` + DisablePathApps clibase.Bool `json:"disable_path_apps,omitempty" typescript:",notnull"` + SessionDuration clibase.Duration `json:"max_session_expiry,omitempty" typescript:",notnull"` + DisableSessionExpiryRefresh clibase.Bool `json:"disable_session_expiry_refresh,omitempty" typescript:",notnull"` + DisablePasswordAuth clibase.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"` + Support SupportConfig `json:"support,omitempty" typescript:",notnull"` + ExternalAuthConfigs clibase.Struct[[]ExternalAuthConfig] `json:"external_auth,omitempty" typescript:",notnull"` + SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` + WgtunnelHost clibase.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` + DisableOwnerWorkspaceExec clibase.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` + ProxyHealthStatusInterval clibase.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` + EnableTerraformDebugMode clibase.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"` + UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` Config clibase.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"` WriteConfig clibase.Bool `json:"write_config,omitempty" typescript:",notnull"` @@ -321,21 +321,34 @@ type TraceConfig struct { DataDog clibase.Bool `json:"data_dog" typescript:",notnull"` } -type GitAuthConfig struct { +type ExternalAuthConfig struct { + // Type is the type of external auth config. + Type string `json:"type"` + ClientID string `json:"client_id"` + ClientSecret string `json:"-" yaml:"client_secret"` + // ID is a unique identifier for the auth config. + // It defaults to `type` when not provided. ID string `json:"id"` - Type string `json:"type"` - ClientID string `json:"client_id"` - ClientSecret string `json:"-" yaml:"client_secret"` AuthURL string `json:"auth_url"` TokenURL string `json:"token_url"` ValidateURL string `json:"validate_url"` AppInstallURL string `json:"app_install_url"` AppInstallationsURL string `json:"app_installations_url"` - Regex string `json:"regex"` NoRefresh bool `json:"no_refresh"` Scopes []string `json:"scopes"` DeviceFlow bool `json:"device_flow"` DeviceCodeURL string `json:"device_code_url"` + // Regex allows API requesters to match an auth config by + // a string (e.g. coder.com) instead of by it's type. + // + // Git clone makes use of this by parsing the URL from: + // 'Username for "https://github.com":' + // And sending it to the Coder server to match against the Regex. + Regex string `json:"regex"` + // DisplayName is shown in the UI to identify the auth config. + DisplayName string `json:"display_name"` + // DisplayIcon is a URL to an icon to display in the UI. + DisplayIcon string `json:"display_icon"` } type ProvisionerConfig struct { @@ -1710,12 +1723,12 @@ Write out the current server config as YAML to stdout.`, }, { // Env handling is done in cli.ReadGitAuthFromEnvironment - Name: "Git Auth Providers", - Description: "Git Authentication providers.", + Name: "External Auth Providers", + Description: "External Authentication providers.", // We need extra scrutiny to ensure this works, is documented, and // tested before enabling. // YAML: "gitAuthProviders", - Value: &c.GitAuthProviders, + Value: &c.ExternalAuthConfigs, Hidden: true, }, { diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index 287e34c741..7cecc28851 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -65,9 +65,9 @@ func TestDeploymentValues_HighlyConfigurable(t *testing.T) { flag: true, env: true, }, - "Git Auth Providers": { - // Technically Git Auth Providers can be provided through the env, - // but bypassing clibase. See cli.ReadGitAuthProvidersFromEnv. + "External Auth Providers": { + // Technically External Auth Providers can be provided through the env, + // but bypassing clibase. See cli.ReadExternalAuthProvidersFromEnv. flag: true, env: true, }, diff --git a/codersdk/externalauth.go b/codersdk/externalauth.go index 5350dc3a6b..6aff5ad63b 100644 --- a/codersdk/externalauth.go +++ b/codersdk/externalauth.go @@ -7,10 +7,39 @@ import ( "net/http" ) +// EnhancedExternalAuthProvider is a constant that represents enhanced +// support for a type of external authentication. All of the Git providers +// are examples of enhanced, because they support intercepting "git clone". +type EnhancedExternalAuthProvider string + +func (e EnhancedExternalAuthProvider) String() string { + return string(e) +} + +// Git returns whether the provider is a Git provider. +func (e EnhancedExternalAuthProvider) Git() bool { + switch e { + case EnhancedExternalAuthProviderGitHub, + EnhancedExternalAuthProviderGitLab, + EnhancedExternalAuthProviderBitBucket, + EnhancedExternalAuthProviderAzureDevops: + return true + default: + return false + } +} + +const ( + EnhancedExternalAuthProviderAzureDevops EnhancedExternalAuthProvider = "azure-devops" + EnhancedExternalAuthProviderGitHub EnhancedExternalAuthProvider = "github" + EnhancedExternalAuthProviderGitLab EnhancedExternalAuthProvider = "gitlab" + EnhancedExternalAuthProviderBitBucket EnhancedExternalAuthProvider = "bitbucket" +) + type ExternalAuth struct { Authenticated bool `json:"authenticated"` Device bool `json:"device"` - Type string `json:"type"` + DisplayName string `json:"display_name"` // User is the user that authenticated with the provider. User *ExternalAuthUser `json:"user"` @@ -50,7 +79,7 @@ type ExternalAuthDeviceExchange struct { } func (c *Client) ExternalAuthDeviceByID(ctx context.Context, provider string) (ExternalAuthDevice, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/externalauth/%s/device", provider), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/external-auth/%s/device", provider), nil) if err != nil { return ExternalAuthDevice{}, err } @@ -64,7 +93,7 @@ func (c *Client) ExternalAuthDeviceByID(ctx context.Context, provider string) (E // ExchangeGitAuth exchanges a device code for an external auth token. func (c *Client) ExternalAuthDeviceExchange(ctx context.Context, provider string, req ExternalAuthDeviceExchange) error { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/externalauth/%s/device", provider), req) + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/external-auth/%s/device", provider), req) if err != nil { return err } @@ -77,7 +106,7 @@ func (c *Client) ExternalAuthDeviceExchange(ctx context.Context, provider string // ExternalAuthByID returns the external auth for the given provider by ID. func (c *Client) ExternalAuthByID(ctx context.Context, provider string) (ExternalAuth, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/externalauth/%s", provider), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/external-auth/%s", provider), nil) if err != nil { return ExternalAuth{}, err } diff --git a/codersdk/templateversions.go b/codersdk/templateversions.go index fb1f0b21fe..773c256e05 100644 --- a/codersdk/templateversions.go +++ b/codersdk/templateversions.go @@ -34,10 +34,12 @@ type TemplateVersion struct { } type TemplateVersionExternalAuth struct { - ID string `json:"id"` - Type ExternalAuthProvider `json:"type"` - AuthenticateURL string `json:"authenticate_url"` - Authenticated bool `json:"authenticated"` + ID string `json:"id"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + DisplayIcon string `json:"display_icon"` + AuthenticateURL string `json:"authenticate_url"` + Authenticated bool `json:"authenticated"` } type ValidationMonotonicOrder string @@ -134,7 +136,7 @@ func (c *Client) TemplateVersionRichParameters(ctx context.Context, version uuid // TemplateVersionExternalAuth returns authentication providers for the requested template version. func (c *Client) TemplateVersionExternalAuth(ctx context.Context, version uuid.UUID) ([]TemplateVersionExternalAuth, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/externalauth", version), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/external-auth", version), nil) if err != nil { return nil, err } diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 0461e3ae07..c59321d607 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -744,35 +744,6 @@ func (c *Client) WorkspaceAgentLogsAfter(ctx context.Context, agentID uuid.UUID, }), nil } -// ExternalAuthProvider is a constant that represents the -// type of providers that are supported within Coder. -type ExternalAuthProvider string - -func (g ExternalAuthProvider) Pretty() string { - switch g { - case ExternalAuthProviderAzureDevops: - return "Azure DevOps" - case ExternalAuthProviderGitHub: - return "GitHub" - case ExternalAuthProviderGitLab: - return "GitLab" - case ExternalAuthProviderBitBucket: - return "Bitbucket" - case ExternalAuthProviderOpenIDConnect: - return "OpenID Connect" - default: - return string(g) - } -} - -const ( - ExternalAuthProviderAzureDevops ExternalAuthProvider = "azure-devops" - ExternalAuthProviderGitHub ExternalAuthProvider = "github" - ExternalAuthProviderGitLab ExternalAuthProvider = "gitlab" - ExternalAuthProviderBitBucket ExternalAuthProvider = "bitbucket" - ExternalAuthProviderOpenIDConnect ExternalAuthProvider = "openid-connect" -) - type WorkspaceAgentLog struct { ID int64 `json:"id"` CreatedAt time.Time `json:"created_at" format:"date-time"` diff --git a/docs/admin/git-providers.md b/docs/admin/external-auth.md similarity index 62% rename from docs/admin/git-providers.md rename to docs/admin/external-auth.md index 0cbd0e00c9..4dbc856a18 100644 --- a/docs/admin/git-providers.md +++ b/docs/admin/external-auth.md @@ -1,41 +1,45 @@ -# Git Providers +# External Authentication -Coder integrates with git providers to automate away the need for developers to -authenticate with repositories within their workspace. +Coder integrates with Git and OpenID Connect to automate away the need for +developers to authenticate with external services within their workspace. -## How it works +## Git Providers When developers use `git` inside their workspace, they are prompted to authenticate. After that, Coder will store and refresh tokens for future operations. ## Configuration -To add a git provider, you'll need to create an OAuth application. The following -providers are supported: +To add an external authentication provider, you'll need to create an OAuth +application. The following providers are supported: -- [GitHub](#github-app) +- [GitHub](#github) - [GitLab](https://docs.gitlab.com/ee/integration/oauth_provider.html) - [BitBucket](https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/) - [Azure DevOps](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=azure-devops) Example callback URL: -`https://coder.example.com/gitauth/primary-github/callback`. Use an arbitrary ID -for your provider (e.g. `primary-github`). +`https://coder.example.com/external-auth/primary-github/callback`. Use an +arbitrary ID for your provider (e.g. `primary-github`). Set the following environment variables to [configure the Coder server](./configure.md): ```env -CODER_GITAUTH_0_ID="primary-github" -CODER_GITAUTH_0_TYPE=github|gitlab|azure-devops|bitbucket -CODER_GITAUTH_0_CLIENT_ID=xxxxxx -CODER_GITAUTH_0_CLIENT_SECRET=xxxxxxx +CODER_EXTERNAL_AUTH_0_ID="primary-github" +CODER_EXTERNAL_AUTH_0_TYPE=github|gitlab|azure-devops|bitbucket| +CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx +CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx + +# Optionally, configure a custom display name and icon +CODER_EXTERNAL_AUTH_0_DISPLAY_NAME="Google Calendar" +CODER_EXTERNAL_AUTH_0_DISPLAY_ICON="https://mycustomicon.com/google.svg" ``` ### GitHub @@ -69,9 +73,9 @@ CODER_GITAUTH_0_CLIENT_SECRET=xxxxxxx GitHub Enterprise requires the following authentication and token URLs: ```env -CODER_GITAUTH_0_VALIDATE_URL="https://github.example.com/login/oauth/access_token/info" -CODER_GITAUTH_0_AUTH_URL="https://github.example.com/login/oauth/authorize" -CODER_GITAUTH_0_TOKEN_URL="https://github.example.com/login/oauth/access_token" +CODER_EXTERNAL_AUTH_0_VALIDATE_URL="https://github.example.com/login/oauth/access_token/info" +CODER_EXTERNAL_AUTH_0_AUTH_URL="https://github.example.com/login/oauth/authorize" +CODER_EXTERNAL_AUTH_0_TOKEN_URL="https://github.example.com/login/oauth/access_token" ``` ### Azure DevOps @@ -79,13 +83,13 @@ CODER_GITAUTH_0_TOKEN_URL="https://github.example.com/login/oauth/access_token" Azure DevOps requires the following environment variables: ```env -CODER_GITAUTH_0_ID="primary-azure-devops" -CODER_GITAUTH_0_TYPE=azure-devops -CODER_GITAUTH_0_CLIENT_ID=xxxxxx +CODER_EXTERNAL_AUTH_0_ID="primary-azure-devops" +CODER_EXTERNAL_AUTH_0_TYPE=azure-devops +CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx # Ensure this value is your "Client Secret", not "App Secret" -CODER_GITAUTH_0_CLIENT_SECRET=xxxxxxx -CODER_GITAUTH_0_AUTH_URL="https://app.vssps.visualstudio.com/oauth2/authorize" -CODER_GITAUTH_0_TOKEN_URL="https://app.vssps.visualstudio.com/oauth2/token" +CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx +CODER_EXTERNAL_AUTH_0_AUTH_URL="https://app.vssps.visualstudio.com/oauth2/authorize" +CODER_EXTERNAL_AUTH_0_TOKEN_URL="https://app.vssps.visualstudio.com/oauth2/token" ``` ### Self-managed git providers @@ -94,9 +98,9 @@ Custom authentication and token URLs should be used for self-managed Git provider deployments. ```env -CODER_GITAUTH_0_AUTH_URL="https://github.example.com/oauth/authorize" -CODER_GITAUTH_0_TOKEN_URL="https://github.example.com/oauth/token" -CODER_GITAUTH_0_VALIDATE_URL="https://your-domain.com/oauth/token/info" +CODER_EXTERNAL_AUTH_0_AUTH_URL="https://github.example.com/oauth/authorize" +CODER_EXTERNAL_AUTH_0_TOKEN_URL="https://github.example.com/oauth/token" +CODER_EXTERNAL_AUTH_0_VALIDATE_URL="https://your-domain.com/oauth/token/info" ``` ### Custom scopes @@ -104,7 +108,7 @@ CODER_GITAUTH_0_VALIDATE_URL="https://your-domain.com/oauth/token/info" Optionally, you can request custom scopes: ```env -CODER_GITAUTH_0_SCOPES="repo:read repo:write write:gpg_key" +CODER_EXTERNAL_AUTH_0_SCOPES="repo:read repo:write write:gpg_key" ``` ### Multiple git providers (enterprise) @@ -116,21 +120,21 @@ limit auth scope. Here's a sample config: ```env # Provider 1) github.com -CODER_GITAUTH_0_ID=primary-github -CODER_GITAUTH_0_TYPE=github -CODER_GITAUTH_0_CLIENT_ID=xxxxxx -CODER_GITAUTH_0_CLIENT_SECRET=xxxxxxx -CODER_GITAUTH_0_REGEX=github.com/orgname +CODER_EXTERNAL_AUTH_0_ID=primary-github +CODER_EXTERNAL_AUTH_0_TYPE=github +CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx +CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx +CODER_EXTERNAL_AUTH_0_REGEX=github.com/orgname # Provider 2) github.example.com -CODER_GITAUTH_1_ID=secondary-github -CODER_GITAUTH_1_TYPE=github -CODER_GITAUTH_1_CLIENT_ID=xxxxxx -CODER_GITAUTH_1_CLIENT_SECRET=xxxxxxx -CODER_GITAUTH_1_REGEX=github.example.com -CODER_GITAUTH_1_AUTH_URL="https://github.example.com/login/oauth/authorize" -CODER_GITAUTH_1_TOKEN_URL="https://github.example.com/login/oauth/access_token" -CODER_GITAUTH_1_VALIDATE_URL="https://github.example.com/login/oauth/access_token/info" +CODER_EXTERNAL_AUTH_1_ID=secondary-github +CODER_EXTERNAL_AUTH_1_TYPE=github +CODER_EXTERNAL_AUTH_1_CLIENT_ID=xxxxxx +CODER_EXTERNAL_AUTH_1_CLIENT_SECRET=xxxxxxx +CODER_EXTERNAL_AUTH_1_REGEX=github.example.com +CODER_EXTERNAL_AUTH_1_AUTH_URL="https://github.example.com/login/oauth/authorize" +CODER_EXTERNAL_AUTH_1_TOKEN_URL="https://github.example.com/login/oauth/access_token" +CODER_EXTERNAL_AUTH_1_VALIDATE_URL="https://github.example.com/login/oauth/access_token/info" ``` To support regex matching for paths (e.g. github.com/orgname), you'll need to diff --git a/docs/api/general.md b/docs/api/general.md index ded8e5df4d..ad2dcf67f0 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -212,8 +212,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ }, "enable_terraform_debug_mode": true, "experiments": ["string"], - "external_token_encryption_keys": ["string"], - "git_auth": { + "external_auth": { "value": [ { "app_install_url": "string", @@ -222,6 +221,8 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "client_id": "string", "device_code_url": "string", "device_flow": true, + "display_icon": "string", + "display_name": "string", "id": "string", "no_refresh": true, "regex": "string", @@ -232,6 +233,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ } ] }, + "external_token_encryption_keys": ["string"], "http_address": "string", "in_memory_database": true, "job_hang_detector_interval": 0, diff --git a/docs/api/git.md b/docs/api/git.md index bcc8889006..9f2014705d 100644 --- a/docs/api/git.md +++ b/docs/api/git.md @@ -6,12 +6,12 @@ ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/externalauth/{externalauth} \ +curl -X GET http://coder-server:8080/api/v2/external-auth/{externalauth} \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /externalauth/{externalauth}` +`GET /external-auth/{externalauth}` ### Parameters @@ -29,6 +29,7 @@ curl -X GET http://coder-server:8080/api/v2/externalauth/{externalauth} \ "app_installable": true, "authenticated": true, "device": true, + "display_name": "string", "installations": [ { "account": { @@ -41,7 +42,6 @@ curl -X GET http://coder-server:8080/api/v2/externalauth/{externalauth} \ "id": 0 } ], - "type": "string", "user": { "avatar_url": "string", "login": "string", @@ -65,12 +65,12 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/externalauth/{externalauth}/device \ +curl -X GET http://coder-server:8080/api/v2/external-auth/{externalauth}/device \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /externalauth/{externalauth}/device` +`GET /external-auth/{externalauth}/device` ### Parameters @@ -106,11 +106,11 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X POST http://coder-server:8080/api/v2/externalauth/{externalauth}/device \ +curl -X POST http://coder-server:8080/api/v2/external-auth/{externalauth}/device \ -H 'Coder-Session-Token: API_KEY' ``` -`POST /externalauth/{externalauth}/device` +`POST /external-auth/{externalauth}/device` ### Parameters diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 00b61e6202..7413bf4b48 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -620,7 +620,7 @@ _None_ -## clibase.Struct-array_codersdk_GitAuthConfig +## clibase.Struct-array_codersdk_ExternalAuthConfig ```json { @@ -632,6 +632,8 @@ _None_ "client_id": "string", "device_code_url": "string", "device_flow": true, + "display_icon": "string", + "display_name": "string", "id": "string", "no_refresh": true, "regex": "string", @@ -646,9 +648,9 @@ _None_ ### Properties -| Name | Type | Required | Restrictions | Description | -| ------- | --------------------------------------------------------- | -------- | ------------ | ----------- | -| `value` | array of [codersdk.GitAuthConfig](#codersdkgitauthconfig) | false | | | +| Name | Type | Required | Restrictions | Description | +| ------- | ------------------------------------------------------------------- | -------- | ------------ | ----------- | +| `value` | array of [codersdk.ExternalAuthConfig](#codersdkexternalauthconfig) | false | | | ## clibase.Struct-array_codersdk_LinkConfig @@ -2043,8 +2045,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in }, "enable_terraform_debug_mode": true, "experiments": ["string"], - "external_token_encryption_keys": ["string"], - "git_auth": { + "external_auth": { "value": [ { "app_install_url": "string", @@ -2053,6 +2054,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "client_id": "string", "device_code_url": "string", "device_flow": true, + "display_icon": "string", + "display_name": "string", "id": "string", "no_refresh": true, "regex": "string", @@ -2063,6 +2066,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in } ] }, + "external_token_encryption_keys": ["string"], "http_address": "string", "in_memory_database": true, "job_hang_detector_interval": 0, @@ -2408,8 +2412,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in }, "enable_terraform_debug_mode": true, "experiments": ["string"], - "external_token_encryption_keys": ["string"], - "git_auth": { + "external_auth": { "value": [ { "app_install_url": "string", @@ -2418,6 +2421,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "client_id": "string", "device_code_url": "string", "device_flow": true, + "display_icon": "string", + "display_name": "string", "id": "string", "no_refresh": true, "regex": "string", @@ -2428,6 +2433,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in } ] }, + "external_token_encryption_keys": ["string"], "http_address": "string", "in_memory_database": true, "job_hang_detector_interval": 0, @@ -2602,62 +2608,62 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------------------------ | ------------------------------------------------------------------------------------------ | -------- | ------------ | ------------------------------------------------------------------ | -| `access_url` | [clibase.URL](#clibaseurl) | false | | | -| `address` | [clibase.HostPort](#clibasehostport) | false | | Address Use HTTPAddress or TLS.Address instead. | -| `agent_fallback_troubleshooting_url` | [clibase.URL](#clibaseurl) | false | | | -| `agent_stat_refresh_interval` | integer | false | | | -| `autobuild_poll_interval` | integer | false | | | -| `browser_only` | boolean | false | | | -| `cache_directory` | string | false | | | -| `config` | string | false | | | -| `config_ssh` | [codersdk.SSHConfig](#codersdksshconfig) | false | | | -| `dangerous` | [codersdk.DangerousConfig](#codersdkdangerousconfig) | false | | | -| `derp` | [codersdk.DERP](#codersdkderp) | false | | | -| `disable_owner_workspace_exec` | boolean | false | | | -| `disable_password_auth` | boolean | false | | | -| `disable_path_apps` | boolean | false | | | -| `disable_session_expiry_refresh` | boolean | false | | | -| `docs_url` | [clibase.URL](#clibaseurl) | false | | | -| `enable_terraform_debug_mode` | boolean | false | | | -| `experiments` | array of string | false | | | -| `external_token_encryption_keys` | array of string | false | | | -| `git_auth` | [clibase.Struct-array_codersdk_GitAuthConfig](#clibasestruct-array_codersdk_gitauthconfig) | false | | | -| `http_address` | string | false | | Http address is a string because it may be set to zero to disable. | -| `in_memory_database` | boolean | false | | | -| `job_hang_detector_interval` | integer | false | | | -| `logging` | [codersdk.LoggingConfig](#codersdkloggingconfig) | false | | | -| `max_session_expiry` | integer | false | | | -| `max_token_lifetime` | integer | false | | | -| `metrics_cache_refresh_interval` | integer | false | | | -| `oauth2` | [codersdk.OAuth2Config](#codersdkoauth2config) | false | | | -| `oidc` | [codersdk.OIDCConfig](#codersdkoidcconfig) | false | | | -| `pg_connection_url` | string | false | | | -| `pprof` | [codersdk.PprofConfig](#codersdkpprofconfig) | false | | | -| `prometheus` | [codersdk.PrometheusConfig](#codersdkprometheusconfig) | false | | | -| `provisioner` | [codersdk.ProvisionerConfig](#codersdkprovisionerconfig) | false | | | -| `proxy_health_status_interval` | integer | false | | | -| `proxy_trusted_headers` | array of string | false | | | -| `proxy_trusted_origins` | array of string | false | | | -| `rate_limit` | [codersdk.RateLimitConfig](#codersdkratelimitconfig) | false | | | -| `redirect_to_access_url` | boolean | false | | | -| `scim_api_key` | string | false | | | -| `secure_auth_cookie` | boolean | false | | | -| `ssh_keygen_algorithm` | string | false | | | -| `strict_transport_security` | integer | false | | | -| `strict_transport_security_options` | array of string | false | | | -| `support` | [codersdk.SupportConfig](#codersdksupportconfig) | false | | | -| `swagger` | [codersdk.SwaggerConfig](#codersdkswaggerconfig) | false | | | -| `telemetry` | [codersdk.TelemetryConfig](#codersdktelemetryconfig) | false | | | -| `tls` | [codersdk.TLSConfig](#codersdktlsconfig) | false | | | -| `trace` | [codersdk.TraceConfig](#codersdktraceconfig) | false | | | -| `update_check` | boolean | false | | | -| `user_quiet_hours_schedule` | [codersdk.UserQuietHoursScheduleConfig](#codersdkuserquiethoursscheduleconfig) | false | | | -| `verbose` | boolean | false | | | -| `wgtunnel_host` | string | false | | | -| `wildcard_access_url` | [clibase.URL](#clibaseurl) | false | | | -| `write_config` | boolean | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------ | +| `access_url` | [clibase.URL](#clibaseurl) | false | | | +| `address` | [clibase.HostPort](#clibasehostport) | false | | Address Use HTTPAddress or TLS.Address instead. | +| `agent_fallback_troubleshooting_url` | [clibase.URL](#clibaseurl) | false | | | +| `agent_stat_refresh_interval` | integer | false | | | +| `autobuild_poll_interval` | integer | false | | | +| `browser_only` | boolean | false | | | +| `cache_directory` | string | false | | | +| `config` | string | false | | | +| `config_ssh` | [codersdk.SSHConfig](#codersdksshconfig) | false | | | +| `dangerous` | [codersdk.DangerousConfig](#codersdkdangerousconfig) | false | | | +| `derp` | [codersdk.DERP](#codersdkderp) | false | | | +| `disable_owner_workspace_exec` | boolean | false | | | +| `disable_password_auth` | boolean | false | | | +| `disable_path_apps` | boolean | false | | | +| `disable_session_expiry_refresh` | boolean | false | | | +| `docs_url` | [clibase.URL](#clibaseurl) | false | | | +| `enable_terraform_debug_mode` | boolean | false | | | +| `experiments` | array of string | false | | | +| `external_auth` | [clibase.Struct-array_codersdk_ExternalAuthConfig](#clibasestruct-array_codersdk_externalauthconfig) | false | | | +| `external_token_encryption_keys` | array of string | false | | | +| `http_address` | string | false | | Http address is a string because it may be set to zero to disable. | +| `in_memory_database` | boolean | false | | | +| `job_hang_detector_interval` | integer | false | | | +| `logging` | [codersdk.LoggingConfig](#codersdkloggingconfig) | false | | | +| `max_session_expiry` | integer | false | | | +| `max_token_lifetime` | integer | false | | | +| `metrics_cache_refresh_interval` | integer | false | | | +| `oauth2` | [codersdk.OAuth2Config](#codersdkoauth2config) | false | | | +| `oidc` | [codersdk.OIDCConfig](#codersdkoidcconfig) | false | | | +| `pg_connection_url` | string | false | | | +| `pprof` | [codersdk.PprofConfig](#codersdkpprofconfig) | false | | | +| `prometheus` | [codersdk.PrometheusConfig](#codersdkprometheusconfig) | false | | | +| `provisioner` | [codersdk.ProvisionerConfig](#codersdkprovisionerconfig) | false | | | +| `proxy_health_status_interval` | integer | false | | | +| `proxy_trusted_headers` | array of string | false | | | +| `proxy_trusted_origins` | array of string | false | | | +| `rate_limit` | [codersdk.RateLimitConfig](#codersdkratelimitconfig) | false | | | +| `redirect_to_access_url` | boolean | false | | | +| `scim_api_key` | string | false | | | +| `secure_auth_cookie` | boolean | false | | | +| `ssh_keygen_algorithm` | string | false | | | +| `strict_transport_security` | integer | false | | | +| `strict_transport_security_options` | array of string | false | | | +| `support` | [codersdk.SupportConfig](#codersdksupportconfig) | false | | | +| `swagger` | [codersdk.SwaggerConfig](#codersdkswaggerconfig) | false | | | +| `telemetry` | [codersdk.TelemetryConfig](#codersdktelemetryconfig) | false | | | +| `tls` | [codersdk.TLSConfig](#codersdktlsconfig) | false | | | +| `trace` | [codersdk.TraceConfig](#codersdktraceconfig) | false | | | +| `update_check` | boolean | false | | | +| `user_quiet_hours_schedule` | [codersdk.UserQuietHoursScheduleConfig](#codersdkuserquiethoursscheduleconfig) | false | | | +| `verbose` | boolean | false | | | +| `wgtunnel_host` | string | false | | | +| `wildcard_access_url` | [clibase.URL](#clibaseurl) | false | | | +| `write_config` | boolean | false | | | ## codersdk.DisplayApp @@ -2760,6 +2766,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "app_installable": true, "authenticated": true, "device": true, + "display_name": "string", "installations": [ { "account": { @@ -2772,7 +2779,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "id": 0 } ], - "type": "string", "user": { "avatar_url": "string", "login": "string", @@ -2790,8 +2796,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `app_installable` | boolean | false | | App installable is true if the request for app installs was successful. | | `authenticated` | boolean | false | | | | `device` | boolean | false | | | +| `display_name` | string | false | | | | `installations` | array of [codersdk.ExternalAuthAppInstallation](#codersdkexternalauthappinstallation) | false | | Installations are the installations that the user has access to. | -| `type` | string | false | | | | `user` | [codersdk.ExternalAuthUser](#codersdkexternalauthuser) | false | | User is the user that authenticated with the provider. | ## codersdk.ExternalAuthAppInstallation @@ -2817,6 +2823,49 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `configure_url` | string | false | | | | `id` | integer | false | | | +## codersdk.ExternalAuthConfig + +```json +{ + "app_install_url": "string", + "app_installations_url": "string", + "auth_url": "string", + "client_id": "string", + "device_code_url": "string", + "device_flow": true, + "display_icon": "string", + "display_name": "string", + "id": "string", + "no_refresh": true, + "regex": "string", + "scopes": ["string"], + "token_url": "string", + "type": "string", + "validate_url": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------- | +| `app_install_url` | string | false | | | +| `app_installations_url` | string | false | | | +| `auth_url` | string | false | | | +| `client_id` | string | false | | | +| `device_code_url` | string | false | | | +| `device_flow` | boolean | false | | | +| `display_icon` | string | false | | Display icon is a URL to an icon to display in the UI. | +| `display_name` | string | false | | Display name is shown in the UI to identify the auth config. | +| `id` | string | false | | ID is a unique identifier for the auth config. It defaults to `type` when not provided. | +| `no_refresh` | boolean | false | | | +| `regex` | string | false | | Regex allows API requesters to match an auth config by a string (e.g. coder.com) instead of by it's type. | +| Git clone makes use of this by parsing the URL from: 'Username for "https://github.com":' And sending it to the Coder server to match against the Regex. | +| `scopes` | array of string | false | | | +| `token_url` | string | false | | | +| `type` | string | false | | Type is the type of external auth config. | +| `validate_url` | string | false | | | + ## codersdk.ExternalAuthDevice ```json @@ -2839,24 +2888,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `user_code` | string | false | | | | `verification_uri` | string | false | | | -## codersdk.ExternalAuthProvider - -```json -"azure-devops" -``` - -### Properties - -#### Enumerated Values - -| Value | -| ---------------- | -| `azure-devops` | -| `github` | -| `gitlab` | -| `bitbucket` | -| `openid-connect` | - ## codersdk.ExternalAuthUser ```json @@ -2945,44 +2976,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `count` | integer | false | | | | `users` | array of [codersdk.User](#codersdkuser) | false | | | -## codersdk.GitAuthConfig - -```json -{ - "app_install_url": "string", - "app_installations_url": "string", - "auth_url": "string", - "client_id": "string", - "device_code_url": "string", - "device_flow": true, - "id": "string", - "no_refresh": true, - "regex": "string", - "scopes": ["string"], - "token_url": "string", - "type": "string", - "validate_url": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| ----------------------- | --------------- | -------- | ------------ | ----------- | -| `app_install_url` | string | false | | | -| `app_installations_url` | string | false | | | -| `auth_url` | string | false | | | -| `client_id` | string | false | | | -| `device_code_url` | string | false | | | -| `device_flow` | boolean | false | | | -| `id` | string | false | | | -| `no_refresh` | boolean | false | | | -| `regex` | string | false | | | -| `scopes` | array of string | false | | | -| `token_url` | string | false | | | -| `type` | string | false | | | -| `validate_url` | string | false | | | - ## codersdk.GitSSHKey ```json @@ -4741,19 +4734,23 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "authenticate_url": "string", "authenticated": true, + "display_icon": "string", + "display_name": "string", "id": "string", - "type": "azure-devops" + "type": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | -------------------------------------------------------------- | -------- | ------------ | ----------- | -| `authenticate_url` | string | false | | | -| `authenticated` | boolean | false | | | -| `id` | string | false | | | -| `type` | [codersdk.ExternalAuthProvider](#codersdkexternalauthprovider) | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------ | ------- | -------- | ------------ | ----------- | +| `authenticate_url` | string | false | | | +| `authenticated` | boolean | false | | | +| `display_icon` | string | false | | | +| `display_name` | string | false | | | +| `id` | string | false | | | +| `type` | string | false | | | ## codersdk.TemplateVersionParameter diff --git a/docs/api/templates.md b/docs/api/templates.md index fe9d76633e..4f0dead495 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -1806,12 +1806,12 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/externalauth \ +curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/external-auth \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /templateversions/{templateversion}/externalauth` +`GET /templateversions/{templateversion}/external-auth` ### Parameters @@ -1828,8 +1828,10 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/e { "authenticate_url": "string", "authenticated": true, + "display_icon": "string", + "display_name": "string", "id": "string", - "type": "azure-devops" + "type": "string" } ] ``` @@ -1844,23 +1846,15 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/e Status Code **200** -| Name | Type | Required | Restrictions | Description | -| -------------------- | ------------------------------------------------------------------------ | -------- | ------------ | ----------- | -| `[array item]` | array | false | | | -| `» authenticate_url` | string | false | | | -| `» authenticated` | boolean | false | | | -| `» id` | string | false | | | -| `» type` | [codersdk.ExternalAuthProvider](schemas.md#codersdkexternalauthprovider) | false | | | - -#### Enumerated Values - -| Property | Value | -| -------- | ---------------- | -| `type` | `azure-devops` | -| `type` | `github` | -| `type` | `gitlab` | -| `type` | `bitbucket` | -| `type` | `openid-connect` | +| Name | Type | Required | Restrictions | Description | +| -------------------- | ------- | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» authenticate_url` | string | false | | | +| `» authenticated` | boolean | false | | | +| `» display_icon` | string | false | | | +| `» display_name` | string | false | | | +| `» id` | string | false | | | +| `» type` | string | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/manifest.json b/docs/manifest.json index bfa75ad20d..e6541f5634 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -308,9 +308,9 @@ "icon_path": "./images/icons/toggle_on.svg" }, { - "title": "Git Providers", - "description": "Learn how connect Coder with external git providers", - "path": "./admin/git-providers.md", + "title": "External Auth", + "description": "Learn how connect Coder with external auth providers", + "path": "./admin/external-auth.md", "icon_path": "./images/icons/git.svg" }, { diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts index b89d3a60d9..e82a4e841d 100644 --- a/site/e2e/playwright.config.ts +++ b/site/e2e/playwright.config.ts @@ -57,6 +57,7 @@ export default defineConfig({ CODER_GITAUTH_0_ID: gitAuth.deviceProvider, CODER_GITAUTH_0_TYPE: "github", CODER_GITAUTH_0_CLIENT_ID: "client", + CODER_GITAUTH_0_CLIENT_SECRET: "secret", CODER_GITAUTH_0_DEVICE_FLOW: "true", CODER_GITAUTH_0_APP_INSTALL_URL: "https://github.com/apps/coder/installations/new", diff --git a/site/e2e/tests/externalAuth.spec.ts b/site/e2e/tests/externalAuth.spec.ts index a0e55e24d8..5480ad4b59 100644 --- a/site/e2e/tests/externalAuth.spec.ts +++ b/site/e2e/tests/externalAuth.spec.ts @@ -46,7 +46,7 @@ test("external auth device", async ({ page }) => { sentPending.done(); }); - await page.goto(`/externalauth/${gitAuth.deviceProvider}`, { + await page.goto(`/external-auth/${gitAuth.deviceProvider}`, { waitUntil: "domcontentloaded", }); await page.getByText(device.user_code).isVisible(); @@ -70,11 +70,11 @@ test("external auth web", async ({ baseURL, page }) => { }); srv.use(gitAuth.authPath, (req, res) => { res.redirect( - `${baseURL}/externalauth/${gitAuth.webProvider}/callback?code=1234&state=` + + `${baseURL}/external-auth/${gitAuth.webProvider}/callback?code=1234&state=` + req.query.state, ); }); - await page.goto(`/externalauth/${gitAuth.webProvider}`, { + await page.goto(`/external-auth/${gitAuth.webProvider}`, { waitUntil: "domcontentloaded", }); // This endpoint doesn't have the installations URL set intentionally! diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 84cbd85472..12e1c70f08 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -110,10 +110,10 @@ const UserAuthSettingsPage = lazy( "./pages/DeploySettingsPage/UserAuthSettingsPage/UserAuthSettingsPage" ), ); -const GitAuthSettingsPage = lazy( +const ExternalAuthSettingsPage = lazy( () => import( - "./pages/DeploySettingsPage/GitAuthSettingsPage/GitAuthSettingsPage" + "./pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage" ), ); const NetworkSettingsPage = lazy( @@ -210,7 +210,7 @@ export const AppRouter: FC = () => { } /> } /> @@ -292,7 +292,10 @@ export const AppRouter: FC = () => { } /> } /> } /> - } /> + } + /> } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index acd3914c42..9dceab9621 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -338,7 +338,7 @@ export const getTemplateVersionExternalAuth = async ( versionId: string, ): Promise => { const response = await axios.get( - `/api/v2/templateversions/${versionId}/externalauth`, + `/api/v2/templateversions/${versionId}/external-auth`, ); return response.data; }; @@ -861,14 +861,14 @@ export const getExperiments = async (): Promise => { export const getExternalAuthProvider = async ( provider: string, ): Promise => { - const resp = await axios.get(`/api/v2/externalauth/${provider}`); + const resp = await axios.get(`/api/v2/external-auth/${provider}`); return resp.data; }; export const getExternalAuthDevice = async ( provider: string, ): Promise => { - const resp = await axios.get(`/api/v2/externalauth/${provider}/device`); + const resp = await axios.get(`/api/v2/external-auth/${provider}/device`); return resp.data; }; @@ -876,7 +876,10 @@ export const exchangeExternalAuthDevice = async ( provider: string, req: TypesGen.ExternalAuthDeviceExchange, ): Promise => { - const resp = await axios.post(`/api/v2/externalauth/${provider}/device`, req); + const resp = await axios.post( + `/api/v2/external-auth/${provider}/device`, + req, + ); return resp.data; }; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3acced5ba7..8c3e262bd5 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -396,7 +396,7 @@ export interface DeploymentValues { readonly disable_session_expiry_refresh?: boolean; readonly disable_password_auth?: boolean; readonly support?: SupportConfig; - readonly git_auth?: GitAuthConfig[]; + readonly external_auth?: ExternalAuthConfig[]; readonly config_ssh?: SSHConfig; readonly wgtunnel_host?: string; readonly disable_owner_workspace_exec?: boolean; @@ -426,7 +426,7 @@ export type Experiments = Experiment[]; export interface ExternalAuth { readonly authenticated: boolean; readonly device: boolean; - readonly type: string; + readonly display_name: string; readonly user?: ExternalAuthUser; readonly app_installable: boolean; readonly installations: ExternalAuthAppInstallation[]; @@ -440,6 +440,25 @@ export interface ExternalAuthAppInstallation { readonly configure_url: string; } +// From codersdk/deployment.go +export interface ExternalAuthConfig { + readonly type: string; + readonly client_id: string; + readonly id: string; + readonly auth_url: string; + readonly token_url: string; + readonly validate_url: string; + readonly app_install_url: string; + readonly app_installations_url: string; + readonly no_refresh: boolean; + readonly scopes: string[]; + readonly device_flow: boolean; + readonly device_code_url: string; + readonly regex: string; + readonly display_name: string; + readonly display_icon: string; +} + // From codersdk/externalauth.go export interface ExternalAuthDevice { readonly device_code: string; @@ -481,23 +500,6 @@ export interface GetUsersResponse { readonly count: number; } -// From codersdk/deployment.go -export interface GitAuthConfig { - readonly id: string; - readonly type: string; - readonly client_id: string; - readonly auth_url: string; - readonly token_url: string; - readonly validate_url: string; - readonly app_install_url: string; - readonly app_installations_url: string; - readonly regex: string; - readonly no_refresh: boolean; - readonly scopes: string[]; - readonly device_flow: boolean; - readonly device_code_url: string; -} - // From codersdk/gitsshkey.go export interface GitSSHKey { readonly user_id: string; @@ -1013,7 +1015,9 @@ export interface TemplateVersion { // From codersdk/templateversions.go export interface TemplateVersionExternalAuth { readonly id: string; - readonly type: ExternalAuthProvider; + readonly type: string; + readonly display_name: string; + readonly display_icon: string; readonly authenticate_url: string; readonly authenticated: boolean; } @@ -1631,6 +1635,19 @@ export const DisplayApps: DisplayApp[] = [ "web_terminal", ]; +// From codersdk/externalauth.go +export type EnhancedExternalAuthProvider = + | "azure-devops" + | "bitbucket" + | "github" + | "gitlab"; +export const EnhancedExternalAuthProviders: EnhancedExternalAuthProvider[] = [ + "azure-devops", + "bitbucket", + "github", + "gitlab", +]; + // From codersdk/deployment.go export type Entitlement = "entitled" | "grace_period" | "not_entitled"; export const Entitlements: Entitlement[] = [ @@ -1656,21 +1673,6 @@ export const Experiments: Experiment[] = [ "workspace_actions", ]; -// From codersdk/workspaceagents.go -export type ExternalAuthProvider = - | "azure-devops" - | "bitbucket" - | "github" - | "gitlab" - | "openid-connect"; -export const ExternalAuthProviders: ExternalAuthProvider[] = [ - "azure-devops", - "bitbucket", - "github", - "gitlab", - "openid-connect", -]; - // From codersdk/deployment.go export type FeatureName = | "advanced_template_scheduling" diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index 5a0478ca4d..9d85626600 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -94,12 +94,16 @@ export const ExternalAuth: Story = { type: "github", authenticated: false, authenticate_url: "", + display_icon: "/icon/github.svg", + display_name: "GitHub", }, { id: "gitlab", type: "gitlab", authenticated: true, authenticate_url: "", + display_icon: "/icon/gitlab.svg", + display_name: "GitLab", }, ], }, diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 2776a43ef3..c473d12ef5 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -163,8 +163,8 @@ export const CreateWorkspacePageView: FC = ({ {externalAuth && externalAuth.length > 0 && ( {externalAuth.map((auth) => ( @@ -174,7 +174,8 @@ export const CreateWorkspacePageView: FC = ({ authenticated={auth.authenticated} externalAuthPollingState={externalAuthPollingState} startPollingExternalAuth={startPollingExternalAuth} - type={auth.type} + displayName={auth.display_name} + displayIcon={auth.display_icon} error={externalAuthErrors[auth.id]} /> ))} diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuth.stories.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuth.stories.tsx index 9c65abc5a0..32d114f67d 100644 --- a/site/src/pages/CreateWorkspacePage/ExternalAuth.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/ExternalAuth.stories.tsx @@ -11,56 +11,64 @@ type Story = StoryObj; export const GithubNotAuthenticated: Story = { args: { - type: "github", + displayIcon: "/icon/github.svg", + displayName: "GitHub", authenticated: false, }, }; export const GithubAuthenticated: Story = { args: { - type: "github", + displayIcon: "/icon/github.svg", + displayName: "GitHub", authenticated: true, }, }; export const GitlabNotAuthenticated: Story = { args: { - type: "gitlab", + displayIcon: "/icon/gitlab.svg", + displayName: "GitLab", authenticated: false, }, }; export const GitlabAuthenticated: Story = { args: { - type: "gitlab", + displayIcon: "/icon/gitlab.svg", + displayName: "GitLab", authenticated: true, }, }; export const AzureDevOpsNotAuthenticated: Story = { args: { - type: "azure-devops", + displayIcon: "/icon/azure-devops.svg", + displayName: "Azure DevOps", authenticated: false, }, }; export const AzureDevOpsAuthenticated: Story = { args: { - type: "azure-devops", + displayIcon: "/icon/azure-devops.svg", + displayName: "Azure DevOps", authenticated: true, }, }; export const BitbucketNotAuthenticated: Story = { args: { - type: "bitbucket", + displayIcon: "/icon/bitbucket.svg", + displayName: "Bitbucket", authenticated: false, }, }; export const BitbucketAuthenticated: Story = { args: { - type: "bitbucket", + displayIcon: "/icon/bitbucket.svg", + displayName: "Bitbucket", authenticated: true, }, }; diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx index 4c685089b7..f84a835c9f 100644 --- a/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx +++ b/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx @@ -1,21 +1,16 @@ +import ReplayIcon from "@mui/icons-material/Replay"; import Button from "@mui/material/Button"; import FormHelperText from "@mui/material/FormHelperText"; -import { SvgIconProps } from "@mui/material/SvgIcon"; import Tooltip from "@mui/material/Tooltip"; -import GitHub from "@mui/icons-material/GitHub"; -import * as TypesGen from "api/typesGenerated"; -import { AzureDevOpsIcon } from "components/Icons/AzureDevOpsIcon"; -import { BitbucketIcon } from "components/Icons/BitbucketIcon"; -import { GitlabIcon } from "components/Icons/GitlabIcon"; -import { FC } from "react"; import { makeStyles } from "@mui/styles"; -import { type ExternalAuthPollingState } from "./CreateWorkspacePage"; -import { Stack } from "components/Stack/Stack"; -import ReplayIcon from "@mui/icons-material/Replay"; import { LoadingButton } from "components/LoadingButton/LoadingButton"; +import { Stack } from "components/Stack/Stack"; +import { FC } from "react"; +import { type ExternalAuthPollingState } from "./CreateWorkspacePage"; export interface ExternalAuthProps { - type: TypesGen.ExternalAuthProvider; + displayName: string; + displayIcon: string; authenticated: boolean; authenticateURL: string; externalAuthPollingState: ExternalAuthPollingState; @@ -25,7 +20,8 @@ export interface ExternalAuthProps { export const ExternalAuth: FC = (props) => { const { - type, + displayName, + displayIcon, authenticated, authenticateURL, externalAuthPollingState, @@ -37,32 +33,9 @@ export const ExternalAuth: FC = (props) => { error: typeof error !== "undefined", }); - let prettyName: string; - let Icon: (props: SvgIconProps) => JSX.Element; - switch (type) { - case "azure-devops": - prettyName = "Azure DevOps"; - Icon = AzureDevOpsIcon; - break; - case "bitbucket": - prettyName = "Bitbucket"; - Icon = BitbucketIcon; - break; - case "github": - prettyName = "GitHub"; - Icon = GitHub as (props: SvgIconProps) => JSX.Element; - break; - case "gitlab": - prettyName = "GitLab"; - Icon = GitlabIcon; - break; - default: - throw new Error("invalid git provider: " + type); - } - return ( = (props) => { href={authenticateURL} variant="contained" size="large" - startIcon={} + startIcon={ + {`${displayName} + } disabled={authenticated} className={styles.button} color={error ? "error" : undefined} @@ -86,8 +66,8 @@ export const ExternalAuth: FC = (props) => { }} > {authenticated - ? `Authenticated with ${prettyName}` - : `Login with ${prettyName}`} + ? `Authenticated with ${displayName}` + : `Login with ${displayName}`} {externalAuthPollingState === "abandoned" && ( diff --git a/site/src/pages/DeploySettingsPage/GitAuthSettingsPage/GitAuthSettingsPage.tsx b/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage.tsx similarity index 52% rename from site/src/pages/DeploySettingsPage/GitAuthSettingsPage/GitAuthSettingsPage.tsx rename to site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage.tsx index cc18a803ea..0fc166a1a3 100644 --- a/site/src/pages/DeploySettingsPage/GitAuthSettingsPage/GitAuthSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage.tsx @@ -2,20 +2,20 @@ import { useDeploySettings } from "components/DeploySettingsLayout/DeploySetting import { FC } from "react"; import { Helmet } from "react-helmet-async"; import { pageTitle } from "utils/page"; -import { GitAuthSettingsPageView } from "./GitAuthSettingsPageView"; +import { ExternalAuthSettingsPageView } from "./ExternalAuthSettingsPageView"; -const GitAuthSettingsPage: FC = () => { +const ExternalAuthSettingsPage: FC = () => { const { deploymentValues: deploymentValues } = useDeploySettings(); return ( <> - {pageTitle("Git Authentication Settings")} + {pageTitle("External Authentication Settings")} - + ); }; -export default GitAuthSettingsPage; +export default ExternalAuthSettingsPage; diff --git a/site/src/pages/DeploySettingsPage/GitAuthSettingsPage/GitAuthSettingsPageView.stories.tsx b/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx similarity index 61% rename from site/src/pages/DeploySettingsPage/GitAuthSettingsPage/GitAuthSettingsPageView.stories.tsx rename to site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx index 1708841caa..cbcb29fd14 100644 --- a/site/src/pages/DeploySettingsPage/GitAuthSettingsPage/GitAuthSettingsPageView.stories.tsx +++ b/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx @@ -1,12 +1,12 @@ -import { GitAuthSettingsPageView } from "./GitAuthSettingsPageView"; +import { ExternalAuthSettingsPageView } from "./ExternalAuthSettingsPageView"; import type { Meta, StoryObj } from "@storybook/react"; -const meta: Meta = { - title: "pages/GitAuthSettingsPageView", - component: GitAuthSettingsPageView, +const meta: Meta = { + title: "pages/ExternalAuthSettingsPageView", + component: ExternalAuthSettingsPageView, args: { config: { - git_auth: [ + external_auth: [ { id: "0000-1111", type: "GitHub", @@ -21,6 +21,8 @@ const meta: Meta = { scopes: [], device_flow: true, device_code_url: "", + display_icon: "", + display_name: "GitHub", }, ], }, @@ -28,6 +30,6 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Page: Story = {}; diff --git a/site/src/pages/DeploySettingsPage/GitAuthSettingsPage/GitAuthSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx similarity index 77% rename from site/src/pages/DeploySettingsPage/GitAuthSettingsPage/GitAuthSettingsPageView.tsx rename to site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx index e56634a93f..d3172f05c9 100644 --- a/site/src/pages/DeploySettingsPage/GitAuthSettingsPage/GitAuthSettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx @@ -5,27 +5,27 @@ import TableCell from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; -import { DeploymentValues, GitAuthConfig } from "api/typesGenerated"; +import { DeploymentValues, ExternalAuthConfig } from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; import { EnterpriseBadge } from "components/DeploySettingsLayout/Badges"; import { Header } from "components/DeploySettingsLayout/Header"; import { docs } from "utils/docs"; -export type GitAuthSettingsPageViewProps = { +export type ExternalAuthSettingsPageViewProps = { config: DeploymentValues; }; -export const GitAuthSettingsPageView = ({ +export const ExternalAuthSettingsPageView = ({ config, -}: GitAuthSettingsPageViewProps): JSX.Element => { +}: ExternalAuthSettingsPageViewProps): JSX.Element => { const styles = useStyles(); return ( <>