feat: allow external services to be authable (#9996)

* feat: allow external services to be authable

* Refactor external auth config structure for defaults

* Add support for new config properties

* Change the name of external auth

* Move externalauth -> external-auth

* Run gen

* Fix tests

* Fix MW tests

* Fix git auth redirect

* Fix lint

* Fix name

* Allow any ID

* Fix invalid type test

* Fix e2e tests

* Fix comments

* Fix colors

* Allow accepting any type as string

* Run gen

* Fix href
This commit is contained in:
Kyle Carberry 2023-10-03 09:04:39 -05:00 committed by GitHub
parent f62f45a303
commit 45b53c285f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 1140 additions and 1027 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

152
coderd/apidoc/docs.go generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.
<video autoplay playsinline loop>
<source src="https://github.com/coder/coder/blob/main/site/static/gitauth.mp4?raw=true" type="video/mp4">
<source src="https://github.com/coder/coder/blob/main/site/static/external-auth.mp4?raw=true" type="video/mp4">
Your browser does not support the video tag.
</video>
## 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|<name of service e.g. jfrog>
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

6
docs/api/general.md generated
View File

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

14
docs/api/git.md generated
View File

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

255
docs/api/schemas.md generated
View File

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

34
docs/api/templates.md generated
View File

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

View File

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

View File

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

View File

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

View File

@ -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 = () => {
<Route path="health" element={<HealthPage />} />
<Route
path="externalauth/:provider"
path="external-auth/:provider"
element={<ExternalAuthPage />}
/>
@ -292,7 +292,10 @@ export const AppRouter: FC = () => {
<Route path="appearance" element={<AppearanceSettingsPage />} />
<Route path="network" element={<NetworkSettingsPage />} />
<Route path="userauth" element={<UserAuthSettingsPage />} />
<Route path="gitauth" element={<GitAuthSettingsPage />} />
<Route
path="external-auth"
element={<ExternalAuthSettingsPage />}
/>
<Route
path="workspace-proxies"
element={<WorkspaceProxyPage />}

View File

@ -338,7 +338,7 @@ export const getTemplateVersionExternalAuth = async (
versionId: string,
): Promise<TypesGen.TemplateVersionExternalAuth[]> => {
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<TypesGen.Experiment[]> => {
export const getExternalAuthProvider = async (
provider: string,
): Promise<TypesGen.ExternalAuth> => {
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<TypesGen.ExternalAuthDevice> => {
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<void> => {
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;
};

View File

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

View File

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

View File

@ -163,8 +163,8 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
{externalAuth && externalAuth.length > 0 && (
<FormSection
title="Git Authentication"
description="This template requires authentication to automatically perform Git operations on create."
title="External Authentication"
description="This template requires authentication to external services."
>
<FormFields>
{externalAuth.map((auth) => (
@ -174,7 +174,8 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
authenticated={auth.authenticated}
externalAuthPollingState={externalAuthPollingState}
startPollingExternalAuth={startPollingExternalAuth}
type={auth.type}
displayName={auth.display_name}
displayIcon={auth.display_icon}
error={externalAuthErrors[auth.id]}
/>
))}

View File

@ -11,56 +11,64 @@ type Story = StoryObj<typeof ExternalAuth>;
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,
},
};

View File

@ -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<ExternalAuthProps> = (props) => {
const {
type,
displayName,
displayIcon,
authenticated,
authenticateURL,
externalAuthPollingState,
@ -37,32 +33,9 @@ export const ExternalAuth: FC<ExternalAuthProps> = (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 (
<Tooltip
title={authenticated && `${prettyName} has already been connected.`}
title={authenticated && `${displayName} has already been connected.`}
>
<Stack alignItems="center" spacing={1}>
<LoadingButton
@ -70,7 +43,14 @@ export const ExternalAuth: FC<ExternalAuthProps> = (props) => {
href={authenticateURL}
variant="contained"
size="large"
startIcon={<Icon />}
startIcon={
<img
src={displayIcon}
alt={`${displayName} Icon`}
width={24}
height={24}
/>
}
disabled={authenticated}
className={styles.button}
color={error ? "error" : undefined}
@ -86,8 +66,8 @@ export const ExternalAuth: FC<ExternalAuthProps> = (props) => {
}}
>
{authenticated
? `Authenticated with ${prettyName}`
: `Login with ${prettyName}`}
? `Authenticated with ${displayName}`
: `Login with ${displayName}`}
</LoadingButton>
{externalAuthPollingState === "abandoned" && (

View File

@ -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 (
<>
<Helmet>
<title>{pageTitle("Git Authentication Settings")}</title>
<title>{pageTitle("External Authentication Settings")}</title>
</Helmet>
<GitAuthSettingsPageView config={deploymentValues.config} />
<ExternalAuthSettingsPageView config={deploymentValues.config} />
</>
);
};
export default GitAuthSettingsPage;
export default ExternalAuthSettingsPage;

View File

@ -1,12 +1,12 @@
import { GitAuthSettingsPageView } from "./GitAuthSettingsPageView";
import { ExternalAuthSettingsPageView } from "./ExternalAuthSettingsPageView";
import type { Meta, StoryObj } from "@storybook/react";
const meta: Meta<typeof GitAuthSettingsPageView> = {
title: "pages/GitAuthSettingsPageView",
component: GitAuthSettingsPageView,
const meta: Meta<typeof ExternalAuthSettingsPageView> = {
title: "pages/ExternalAuthSettingsPageView",
component: ExternalAuthSettingsPageView,
args: {
config: {
git_auth: [
external_auth: [
{
id: "0000-1111",
type: "GitHub",
@ -21,6 +21,8 @@ const meta: Meta<typeof GitAuthSettingsPageView> = {
scopes: [],
device_flow: true,
device_code_url: "",
display_icon: "",
display_name: "GitHub",
},
],
},
@ -28,6 +30,6 @@ const meta: Meta<typeof GitAuthSettingsPageView> = {
};
export default meta;
type Story = StoryObj<typeof GitAuthSettingsPageView>;
type Story = StoryObj<typeof ExternalAuthSettingsPageView>;
export const Page: Story = {};

View File

@ -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 (
<>
<Header
title="Git Authentication"
description="Coder integrates with GitHub, GitLab, BitBucket, and Azure Repos to authenticate developers with your Git provider."
docsHref={docs("/admin/git-providers")}
title="External Authentication"
description="Coder integrates with GitHub, GitLab, BitBucket, Azure Repos, and OpenID Connect to authenticate developers with external services."
docsHref={docs("/admin/external-auth")}
/>
<video
@ -33,7 +33,7 @@ export const GitAuthSettingsPageView = ({
muted
loop
playsInline
src="/gitauth.mp4"
src="/external-auth.mp4"
style={{
maxWidth: "100%",
borderRadius: 4,
@ -42,7 +42,8 @@ export const GitAuthSettingsPageView = ({
<div className={styles.description}>
<Alert severity="info" actions={<EnterpriseBadge key="enterprise" />}>
Integrating with multiple Git providers is an Enterprise feature.
Integrating with multiple External authentication providers is an
Enterprise feature.
</Alert>
</div>
@ -56,7 +57,8 @@ export const GitAuthSettingsPageView = ({
</TableRow>
</TableHead>
<TableBody>
{((config.git_auth === null || config.git_auth?.length === 0) && (
{((config.external_auth === null ||
config.external_auth?.length === 0) && (
<TableRow>
<TableCell colSpan={999}>
<div className={styles.empty}>
@ -65,7 +67,7 @@ export const GitAuthSettingsPageView = ({
</TableCell>
</TableRow>
)) ||
config.git_auth?.map((git: GitAuthConfig) => {
config.external_auth?.map((git: ExternalAuthConfig) => {
const name = git.id || git.type;
return (
<TableRow key={name}>

View File

@ -72,8 +72,7 @@ const ExternalAuthPage: FC = () => {
!getExternalAuthProviderQuery.data.authenticated &&
!getExternalAuthProviderQuery.data.device
) {
window.location.href = `/externalauth/${provider}/callback`;
window.location.href = `/external-auth/${provider}/callback`;
return null;
}

View File

@ -15,12 +15,12 @@ const Template: StoryFn<ExternalAuthPageViewProps> = (args) => (
export const WebAuthenticated = Template.bind({});
WebAuthenticated.args = {
externalAuth: {
type: "BitBucket",
authenticated: true,
device: false,
installations: [],
app_install_url: "",
app_installable: false,
display_name: "BitBucket",
user: {
avatar_url: "",
login: "kylecarbs",
@ -33,7 +33,7 @@ WebAuthenticated.args = {
export const DeviceUnauthenticated = Template.bind({});
DeviceUnauthenticated.args = {
externalAuth: {
type: "GitHub",
display_name: "GitHub",
authenticated: false,
device: true,
installations: [],
@ -52,7 +52,7 @@ DeviceUnauthenticated.args = {
export const DeviceUnauthenticatedError = Template.bind({});
DeviceUnauthenticatedError.args = {
externalAuth: {
type: "GitHub",
display_name: "GitHub",
authenticated: false,
device: true,
installations: [],
@ -76,7 +76,7 @@ export const DeviceAuthenticatedNotInstalled = Template.bind({});
DeviceAuthenticatedNotInstalled.args = {
viewExternalAuthConfig: true,
externalAuth: {
type: "GitHub",
display_name: "GitHub",
authenticated: true,
device: true,
installations: [],
@ -94,7 +94,7 @@ DeviceAuthenticatedNotInstalled.args = {
export const DeviceAuthenticatedInstalled = Template.bind({});
DeviceAuthenticatedInstalled.args = {
externalAuth: {
type: "GitHub",
display_name: "GitHub",
authenticated: true,
device: true,
installations: [

View File

@ -35,7 +35,7 @@ const ExternalAuthPageView: FC<ExternalAuthPageViewProps> = ({
if (!externalAuth.authenticated) {
return (
<SignInLayout>
<Welcome message={`Authenticate with ${externalAuth.type}`} />
<Welcome message={`Authenticate with ${externalAuth.display_name}`} />
{externalAuth.device && (
<GitDeviceAuth
@ -50,9 +50,7 @@ const ExternalAuthPageView: FC<ExternalAuthPageViewProps> = ({
const hasInstallations = externalAuth.installations.length > 0;
// We only want to wrap this with a link if an install URL is available!
let installTheApp: JSX.Element = (
<>{`install the ${externalAuth.type} App`}</>
);
let installTheApp: React.ReactNode = `install the ${externalAuth.display_name} App`;
if (externalAuth.app_install_url) {
installTheApp = (
<Link
@ -67,12 +65,14 @@ const ExternalAuthPageView: FC<ExternalAuthPageViewProps> = ({
return (
<SignInLayout>
<Welcome message={`You've authenticated with ${externalAuth.type}!`} />
<Welcome
message={`You've authenticated with ${externalAuth.display_name}!`}
/>
<p className={styles.text}>
Hey @{externalAuth.user?.login}! 👋{" "}
{externalAuth.user?.login && `Hey @${externalAuth.user?.login}! 👋 `}
{(!externalAuth.app_installable ||
externalAuth.installations.length > 0) &&
"You are now authenticated with Git. Feel free to close this window!"}
"You are now authenticated. Feel free to close this window!"}
</p>
{externalAuth.installations.length > 0 && (
@ -126,7 +126,7 @@ const ExternalAuthPageView: FC<ExternalAuthPageViewProps> = ({
{externalAuth.installations.length > 0
? "Configure"
: "Install"}{" "}
the {externalAuth.type} App
the {externalAuth.display_name} App
</Link>
)}
<Link

View File

@ -2188,16 +2188,20 @@ export const MockTemplateVersionExternalAuthGithub: TypesGen.TemplateVersionExte
{
id: "github",
type: "github",
authenticate_url: "https://example.com/externalauth/github",
authenticate_url: "https://example.com/external-auth/github",
authenticated: false,
display_icon: "/icon/github.svg",
display_name: "GitHub",
};
export const MockTemplateVersionExternalAuthGithubAuthenticated: TypesGen.TemplateVersionExternalAuth =
{
id: "github",
type: "github",
authenticate_url: "https://example.com/externalauth/github",
authenticate_url: "https://example.com/external-auth/github",
authenticated: true,
display_icon: "/icon/github.svg",
display_name: "GitHub",
};
export const MockDeploymentStats: TypesGen.DeploymentStats = {

View File

@ -112,7 +112,7 @@ export const handlers = [
},
),
rest.get(
"/api/v2/templateversions/:templateVersionId/externalauth",
"/api/v2/templateversions/:templateVersionId/external-auth",
async (req, res, ctx) => {
return res(ctx.status(200), ctx.json([]));
},

View File

@ -1,7 +1,4 @@
import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon";
export const AzureDevOpsIcon = (props: SvgIconProps): JSX.Element => (
<SvgIcon {...props} viewBox="0 0 111 110">
<svg viewBox="0 0 111 110" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_1916_993)">
<path
d="M83.0365 94.769L0 82.288L83.0365 110L111 98.365V11.2115L83.0365 0V94.769Z"
@ -21,5 +18,4 @@ export const AzureDevOpsIcon = (props: SvgIconProps): JSX.Element => (
<rect width="111" height="110" fill="white" />
</clipPath>
</defs>
</SvgIcon>
);
</svg>

Before

Width:  |  Height:  |  Size: 819 B

After

Width:  |  Height:  |  Size: 695 B

View File

@ -1,8 +1,5 @@
import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon";
export const BitbucketIcon = (props: SvgIconProps): JSX.Element => (
<SvgIcon {...props} viewBox="0 0 501 450">
<g clipPath="url(#clip0_1917_1001)">
<svg viewBox="0 0 501 450" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1917_1001)">
<path
d="M17.0206 0.0721333C14.6826 0.0419786 12.3663 0.523969 10.2344 1.48427C8.10245 2.44457 6.20658 3.8599 4.67987 5.63088C3.15316 7.40186 2.03262 9.48557 1.39691 11.7357C0.761211 13.9858 0.625758 16.3479 1.00007 18.6559L69.0071 431.504C69.8544 436.556 72.4548 441.148 76.3515 444.474C80.2481 447.799 85.1919 449.645 90.3144 449.688H416.572C420.412 449.737 424.142 448.405 427.082 445.935C430.023 443.465 431.978 440.021 432.592 436.23L500.6 18.736C500.974 16.428 500.838 14.0659 500.203 11.8158C499.567 9.56568 498.446 7.48197 496.92 5.71098C495.393 3.94 493.497 2.52467 491.365 1.56437C489.233 0.604073 486.917 0.122079 484.579 0.152234L17.0206 0.0721333ZM303.387 298.454H199.254L171.058 151.146H328.619L303.387 298.454Z"
fill="#2684FF"
@ -21,12 +18,11 @@ export const BitbucketIcon = (props: SvgIconProps): JSX.Element => (
y2="386.327"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.18" stopColor="#0052CC" />
<stop offset="1" stopColor="#2684FF" />
<stop offset="0.18" stop-color="#0052CC" />
<stop offset="1" stop-color="#2684FF" />
</linearGradient>
<clipPath id="clip0_1917_1001">
<rect width="501" height="450" fill="white" />
</clipPath>
</defs>
</SvgIcon>
);
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1 @@
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 961 B

View File

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 194 186">
<g clipPath="url(#clip0_1915_987)">
<path
d="M189.83 73.7299L189.56 73.0399L163.42 4.81995C162.888 3.48288 161.946 2.34863 160.73 1.57996C159.513 0.82434 158.093 0.460411 156.662 0.537307C155.232 0.614203 153.859 1.12822 152.73 2.00996C151.613 2.91701 150.803 4.14609 150.41 5.52995L132.76 59.53H61.2899L43.6399 5.52995C43.2571 4.13855 42.4453 2.90331 41.3199 1.99995C40.1907 1.11822 38.8181 0.6042 37.3875 0.527305C35.9569 0.450409 34.5371 0.814338 33.3199 1.56995C32.1062 2.34173 31.1653 3.47499 30.6299 4.80995L4.43991 73L4.17991 73.69C0.416933 83.522 -0.0475445 94.3109 2.8565 104.43C5.76055 114.549 11.8757 123.45 20.2799 129.79L20.3699 129.86L20.6099 130.03L60.4299 159.85L80.1299 174.76L92.1299 183.82C93.5336 184.886 95.2475 185.463 97.0099 185.463C98.7723 185.463 100.486 184.886 101.89 183.82L113.89 174.76L133.59 159.85L173.65 129.85L173.75 129.77C182.135 123.429 188.236 114.537 191.136 104.432C194.035 94.3262 193.577 83.5527 189.83 73.7299Z"
fill="#E24329"
/>
<path
d="M189.83 73.7299L189.56 73.0399C176.823 75.6543 164.82 81.0495 154.41 88.8399L97 132.25C116.55 147.04 133.57 159.89 133.57 159.89L173.63 129.89L173.73 129.81C182.127 123.469 188.238 114.572 191.141 104.457C194.045 94.3434 193.585 83.5598 189.83 73.7299Z"
fill="#FC6D26"
/>
<path
d="M60.4299 159.89L80.1299 174.8L92.1299 183.86C93.5336 184.926 95.2475 185.503 97.0099 185.503C98.7723 185.503 100.486 184.926 101.89 183.86L113.89 174.8L133.59 159.89C133.59 159.89 116.55 147 96.9999 132.25C77.4499 147 60.4299 159.89 60.4299 159.89Z"
fill="#FCA326"
/>
<path
d="M39.5799 88.84C29.1778 81.0335 17.1779 75.6243 4.43991 73L4.17991 73.69C0.416933 83.5221 -0.0475445 94.311 2.8565 104.43C5.76055 114.549 11.8757 123.45 20.2799 129.79L20.3699 129.86L20.6099 130.03L60.4299 159.85C60.4299 159.85 77.4299 147 96.9999 132.21L39.5799 88.84Z"
fill="#FC6D26"
/>
</g>
<defs>
<clipPath id="clip0_1915_987">
<rect width="194" height="186" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB