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" "github.com/coder/coder/v2/codersdk"
) )
type GitAuthOptions struct { type ExternalAuthOptions struct {
Fetch func(context.Context) ([]codersdk.TemplateVersionExternalAuth, error) Fetch func(context.Context) ([]codersdk.TemplateVersionExternalAuth, error)
FetchInterval time.Duration 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 { if opts.FetchInterval == 0 {
opts.FetchInterval = 500 * time.Millisecond opts.FetchInterval = 500 * time.Millisecond
} }
@ -38,7 +38,7 @@ func GitAuth(ctx context.Context, writer io.Writer, opts GitAuthOptions) error {
return nil 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) ticker.Reset(opts.FetchInterval)
spin.Start() spin.Start()
@ -66,7 +66,7 @@ func GitAuth(ctx context.Context, writer io.Writer, opts GitAuthOptions) error {
} }
} }
spin.Stop() 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 return nil
} }

View File

@ -15,7 +15,7 @@ import (
"github.com/coder/coder/v2/testutil" "github.com/coder/coder/v2/testutil"
) )
func TestGitAuth(t *testing.T) { func TestExternalAuth(t *testing.T) {
t.Parallel() t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
@ -25,12 +25,13 @@ func TestGitAuth(t *testing.T) {
cmd := &clibase.Cmd{ cmd := &clibase.Cmd{
Handler: func(inv *clibase.Invocation) error { Handler: func(inv *clibase.Invocation) error {
var fetched atomic.Bool 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) { Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionExternalAuth, error) {
defer fetched.Store(true) defer fetched.Store(true)
return []codersdk.TemplateVersionExternalAuth{{ return []codersdk.TemplateVersionExternalAuth{{
ID: "github", ID: "github",
Type: codersdk.ExternalAuthProviderGitHub, DisplayName: "GitHub",
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
Authenticated: fetched.Load(), Authenticated: fetched.Load(),
AuthenticateURL: "https://example.com/gitauth/github", AuthenticateURL: "https://example.com/gitauth/github",
}}, nil }}, nil

View File

@ -265,7 +265,7 @@ func prepWorkspaceBuild(inv *clibase.Invocation, client *codersdk.Client, args p
return nil, err 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) { Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionExternalAuth, error) {
return client.TemplateVersionExternalAuth(ctx, templateVersion.ID) return client.TemplateVersionExternalAuth(ctx, templateVersion.ID)
}, },

View File

@ -613,7 +613,8 @@ func TestCreateWithGitAuth(t *testing.T) {
OAuth2Config: &testutil.OAuth2Config{}, OAuth2Config: &testutil.OAuth2Config{},
ID: "github", ID: "github",
Regex: regexp.MustCompile(`github\.com`), Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub, Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
DisplayName: "GitHub",
}}, }},
IncludeProvisionerDaemon: true, IncludeProvisionerDaemon: true,
}) })

View File

@ -98,85 +98,6 @@ import (
"github.com/coder/wgtunnel/tunnelsdk" "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) { func createOIDCConfig(ctx context.Context, vals *codersdk.DeploymentValues) (*coderd.OIDCConfig, error) {
if vals.OIDC.ClientID == "" { if vals.OIDC.ClientID == "" {
return nil, xerrors.Errorf("OIDC client ID must be set!") 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 { 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( externalAuthConfigs, err := externalauth.ConvertConfig(
vals.GitAuthProviders.Value, vals.ExternalAuthConfigs.Value,
vals.AccessURL.Value(), vals.AccessURL.Value(),
) )
if err != nil { if err != nil {
@ -816,7 +737,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
if vals.Telemetry.Enable { if vals.Telemetry.Enable {
gitAuth := make([]telemetry.GitAuth, 0) gitAuth := make([]telemetry.GitAuth, 0)
// TODO: // TODO:
var gitAuthConfigs []codersdk.GitAuthConfig var gitAuthConfigs []codersdk.ExternalAuthConfig
for _, cfg := range gitAuthConfigs { for _, cfg := range gitAuthConfigs {
gitAuth = append(gitAuth, telemetry.GitAuth{ gitAuth = append(gitAuth, telemetry.GitAuth{
Type: cfg.Type, Type: cfg.Type,
@ -2242,3 +2163,101 @@ func ConfigureHTTPServers(inv *clibase.Invocation, cfg *codersdk.DeploymentValue
return httpServers, nil 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" "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) { func TestReadGitAuthProvidersFromEnv(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("Empty", func(t *testing.T) { t.Run("Empty", func(t *testing.T) {
t.Parallel() t.Parallel()
providers, err := cli.ReadGitAuthProvidersFromEnv([]string{ providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{
"HOME=/home/frodo", "HOME=/home/frodo",
}) })
require.NoError(t, err) require.NoError(t, err)
@ -61,7 +100,7 @@ func TestReadGitAuthProvidersFromEnv(t *testing.T) {
}) })
t.Run("InvalidKey", func(t *testing.T) { t.Run("InvalidKey", func(t *testing.T) {
t.Parallel() t.Parallel()
providers, err := cli.ReadGitAuthProvidersFromEnv([]string{ providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{
"CODER_GITAUTH_XXX=invalid", "CODER_GITAUTH_XXX=invalid",
}) })
require.Error(t, err, "providers: %+v", providers) require.Error(t, err, "providers: %+v", providers)
@ -69,7 +108,7 @@ func TestReadGitAuthProvidersFromEnv(t *testing.T) {
}) })
t.Run("SkipKey", func(t *testing.T) { t.Run("SkipKey", func(t *testing.T) {
t.Parallel() t.Parallel()
providers, err := cli.ReadGitAuthProvidersFromEnv([]string{ providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{
"CODER_GITAUTH_0_ID=invalid", "CODER_GITAUTH_0_ID=invalid",
"CODER_GITAUTH_2_ID=invalid", "CODER_GITAUTH_2_ID=invalid",
}) })
@ -78,7 +117,7 @@ func TestReadGitAuthProvidersFromEnv(t *testing.T) {
}) })
t.Run("Valid", func(t *testing.T) { t.Run("Valid", func(t *testing.T) {
t.Parallel() t.Parallel()
providers, err := cli.ReadGitAuthProvidersFromEnv([]string{ providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{
"CODER_GITAUTH_0_ID=1", "CODER_GITAUTH_0_ID=1",
"CODER_GITAUTH_0_TYPE=gitlab", "CODER_GITAUTH_0_TYPE=gitlab",
"CODER_GITAUTH_1_ID=2", "CODER_GITAUTH_1_ID=2",

View File

@ -331,17 +331,17 @@ func main() {
// Complete the auth! // Complete the auth!
gitlabAuthed.Store(true) 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) { Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionExternalAuth, error) {
count.Add(1) count.Add(1)
return []codersdk.TemplateVersionExternalAuth{{ return []codersdk.TemplateVersionExternalAuth{{
ID: "github", ID: "github",
Type: codersdk.ExternalAuthProviderGitHub, Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
Authenticated: githubAuthed.Load(), Authenticated: githubAuthed.Load(),
AuthenticateURL: "https://example.com/gitauth/github?redirect=" + url.QueryEscape("/gitauth?notify"), AuthenticateURL: "https://example.com/gitauth/github?redirect=" + url.QueryEscape("/gitauth?notify"),
}, { }, {
ID: "gitlab", ID: "gitlab",
Type: codersdk.ExternalAuthProviderGitLab, Type: codersdk.EnhancedExternalAuthProviderGitLab.String(),
Authenticated: gitlabAuthed.Load(), Authenticated: gitlabAuthed.Load(),
AuthenticateURL: "https://example.com/gitauth/gitlab?redirect=" + url.QueryEscape("/gitauth?notify"), AuthenticateURL: "https://example.com/gitauth/gitlab?redirect=" + url.QueryEscape("/gitauth?notify"),
}}, nil }}, nil

152
coderd/apidoc/docs.go generated
View File

@ -602,7 +602,7 @@ const docTemplate = `{
} }
} }
}, },
"/externalauth/{externalauth}": { "/external-auth/{externalauth}": {
"get": { "get": {
"security": [ "security": [
{ {
@ -637,7 +637,7 @@ const docTemplate = `{
} }
} }
}, },
"/externalauth/{externalauth}/device": { "/external-auth/{externalauth}/device": {
"get": { "get": {
"security": [ "security": [
{ {
@ -2768,7 +2768,7 @@ const docTemplate = `{
} }
} }
}, },
"/templateversions/{templateversion}/externalauth": { "/templateversions/{templateversion}/external-auth": {
"get": { "get": {
"security": [ "security": [
{ {
@ -6725,13 +6725,13 @@ const docTemplate = `{
"clibase.Regexp": { "clibase.Regexp": {
"type": "object" "type": "object"
}, },
"clibase.Struct-array_codersdk_GitAuthConfig": { "clibase.Struct-array_codersdk_ExternalAuthConfig": {
"type": "object", "type": "object",
"properties": { "properties": {
"value": { "value": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/codersdk.GitAuthConfig" "$ref": "#/definitions/codersdk.ExternalAuthConfig"
} }
} }
} }
@ -7978,15 +7978,15 @@ const docTemplate = `{
"type": "string" "type": "string"
} }
}, },
"external_auth": {
"$ref": "#/definitions/clibase.Struct-array_codersdk_ExternalAuthConfig"
},
"external_token_encryption_keys": { "external_token_encryption_keys": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
} }
}, },
"git_auth": {
"$ref": "#/definitions/clibase.Struct-array_codersdk_GitAuthConfig"
},
"http_address": { "http_address": {
"description": "HTTPAddress is a string because it may be set to zero to disable.", "description": "HTTPAddress is a string because it may be set to zero to disable.",
"type": "string" "type": "string"
@ -8203,6 +8203,9 @@ const docTemplate = `{
"device": { "device": {
"type": "boolean" "type": "boolean"
}, },
"display_name": {
"type": "string"
},
"installations": { "installations": {
"description": "AppInstallations are the installations that the user has access to.", "description": "AppInstallations are the installations that the user has access to.",
"type": "array", "type": "array",
@ -8210,9 +8213,6 @@ const docTemplate = `{
"$ref": "#/definitions/codersdk.ExternalAuthAppInstallation" "$ref": "#/definitions/codersdk.ExternalAuthAppInstallation"
} }
}, },
"type": {
"type": "string"
},
"user": { "user": {
"description": "User is the user that authenticated with the provider.", "description": "User is the user that authenticated with the provider.",
"allOf": [ "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": { "codersdk.ExternalAuthDevice": {
"type": "object", "type": "object",
"properties": { "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": { "codersdk.ExternalAuthUser": {
"type": "object", "type": "object",
"properties": { "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": { "codersdk.GitSSHKey": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -10018,11 +10012,17 @@ const docTemplate = `{
"authenticated": { "authenticated": {
"type": "boolean" "type": "boolean"
}, },
"display_icon": {
"type": "string"
},
"display_name": {
"type": "string"
},
"id": { "id": {
"type": "string" "type": "string"
}, },
"type": { "type": {
"$ref": "#/definitions/codersdk.ExternalAuthProvider" "type": "string"
} }
} }
}, },

View File

@ -512,7 +512,7 @@
} }
} }
}, },
"/externalauth/{externalauth}": { "/external-auth/{externalauth}": {
"get": { "get": {
"security": [ "security": [
{ {
@ -543,7 +543,7 @@
} }
} }
}, },
"/externalauth/{externalauth}/device": { "/external-auth/{externalauth}/device": {
"get": { "get": {
"security": [ "security": [
{ {
@ -2430,7 +2430,7 @@
} }
} }
}, },
"/templateversions/{templateversion}/externalauth": { "/templateversions/{templateversion}/external-auth": {
"get": { "get": {
"security": [ "security": [
{ {
@ -5961,13 +5961,13 @@
"clibase.Regexp": { "clibase.Regexp": {
"type": "object" "type": "object"
}, },
"clibase.Struct-array_codersdk_GitAuthConfig": { "clibase.Struct-array_codersdk_ExternalAuthConfig": {
"type": "object", "type": "object",
"properties": { "properties": {
"value": { "value": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/codersdk.GitAuthConfig" "$ref": "#/definitions/codersdk.ExternalAuthConfig"
} }
} }
} }
@ -7130,15 +7130,15 @@
"type": "string" "type": "string"
} }
}, },
"external_auth": {
"$ref": "#/definitions/clibase.Struct-array_codersdk_ExternalAuthConfig"
},
"external_token_encryption_keys": { "external_token_encryption_keys": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
} }
}, },
"git_auth": {
"$ref": "#/definitions/clibase.Struct-array_codersdk_GitAuthConfig"
},
"http_address": { "http_address": {
"description": "HTTPAddress is a string because it may be set to zero to disable.", "description": "HTTPAddress is a string because it may be set to zero to disable.",
"type": "string" "type": "string"
@ -7351,6 +7351,9 @@
"device": { "device": {
"type": "boolean" "type": "boolean"
}, },
"display_name": {
"type": "string"
},
"installations": { "installations": {
"description": "AppInstallations are the installations that the user has access to.", "description": "AppInstallations are the installations that the user has access to.",
"type": "array", "type": "array",
@ -7358,9 +7361,6 @@
"$ref": "#/definitions/codersdk.ExternalAuthAppInstallation" "$ref": "#/definitions/codersdk.ExternalAuthAppInstallation"
} }
}, },
"type": {
"type": "string"
},
"user": { "user": {
"description": "User is the user that authenticated with the provider.", "description": "User is the user that authenticated with the provider.",
"allOf": [ "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": { "codersdk.ExternalAuthDevice": {
"type": "object", "type": "object",
"properties": { "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": { "codersdk.ExternalAuthUser": {
"type": "object", "type": "object",
"properties": { "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": { "codersdk.GitSSHKey": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -9065,11 +9059,17 @@
"authenticated": { "authenticated": {
"type": "boolean" "type": "boolean"
}, },
"display_icon": {
"type": "string"
},
"display_name": {
"type": "string"
},
"id": { "id": {
"type": "string" "type": "string"
}, },
"type": { "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. // Register callback handlers for each OAuth2 provider.
// We must support gitauth and externalauth for backwards compatibility. // 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) { r.Route("/"+route, func(r chi.Router) {
for _, externalAuthConfig := range options.ExternalAuthConfigs { for _, externalAuthConfig := range options.ExternalAuthConfigs {
// We don't need to register a callback handler for device auth. // 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.Get("/{fileID}", api.fileByID)
r.Post("/", api.postFile) r.Post("/", api.postFile)
}) })
r.Route("/externalauth/{externalauth}", func(r chi.Router) { r.Route("/external-auth/{externalauth}", func(r chi.Router) {
r.Use( r.Use(
apiKeyMiddleware, apiKeyMiddleware,
httpmw.ExtractExternalAuthParam(options.ExternalAuthConfigs), httpmw.ExtractExternalAuthParam(options.ExternalAuthConfigs),
@ -689,7 +689,7 @@ func New(options *Options) *API {
r.Get("/schema", templateVersionSchemaDeprecated) r.Get("/schema", templateVersionSchemaDeprecated)
r.Get("/parameters", templateVersionParametersDeprecated) r.Get("/parameters", templateVersionParametersDeprecated)
r.Get("/rich-parameters", api.templateVersionRichParameters) r.Get("/rich-parameters", api.templateVersionRichParameters)
r.Get("/externalauth", api.templateVersionExternalAuth) r.Get("/external-auth", api.templateVersionExternalAuth)
r.Get("/variables", api.templateVersionVariables) r.Get("/variables", api.templateVersionVariables)
r.Get("/resources", api.templateVersionResources) r.Get("/resources", api.templateVersionResources)
r.Get("/logs", api.templateVersionLogs) r.Get("/logs", api.templateVersionLogs)

View File

@ -906,7 +906,7 @@ func RequestExternalAuthCallback(t *testing.T, providerID string, client *coders
return http.ErrUseLastResponse return http.ErrUseLastResponse
} }
state := "somestate" 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) require.NoError(t, err)
req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
require.NoError(t, err) require.NoError(t, err)

View File

@ -643,7 +643,7 @@ CREATE TABLE template_versions (
message character varying(1048576) DEFAULT ''::character varying NOT NULL 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.'; 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 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; COMMIT;

View File

@ -1857,7 +1857,7 @@ type TemplateVersionTable struct {
Readme string `db:"readme" json:"readme"` Readme string `db:"readme" json:"readme"`
JobID uuid.UUID `db:"job_id" json:"job_id"` JobID uuid.UUID `db:"job_id" json:"job_id"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"` 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"` 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 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"` Message string `db:"message" json:"message"`

View File

@ -23,7 +23,7 @@ import (
// @Tags Git // @Tags Git
// @Param externalauth path string true "Git Provider ID" format(string) // @Param externalauth path string true "Git Provider ID" format(string)
// @Success 200 {object} codersdk.ExternalAuth // @Success 200 {object} codersdk.ExternalAuth
// @Router /externalauth/{externalauth} [get] // @Router /external-auth/{externalauth} [get]
func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) { func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) {
config := httpmw.ExternalAuthParam(r) config := httpmw.ExternalAuthParam(r)
apiKey := httpmw.APIKey(r) apiKey := httpmw.APIKey(r)
@ -33,7 +33,7 @@ func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) {
Authenticated: false, Authenticated: false,
Device: config.DeviceAuth != nil, Device: config.DeviceAuth != nil,
AppInstallURL: config.AppInstallURL, AppInstallURL: config.AppInstallURL,
Type: config.Type.Pretty(), DisplayName: config.DisplayName,
AppInstallations: []codersdk.ExternalAuthAppInstallation{}, AppInstallations: []codersdk.ExternalAuthAppInstallation{},
} }
@ -82,7 +82,7 @@ func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) {
// @Tags Git // @Tags Git
// @Param externalauth path string true "External Provider ID" format(string) // @Param externalauth path string true "External Provider ID" format(string)
// @Success 204 // @Success 204
// @Router /externalauth/{externalauth}/device [post] // @Router /external-auth/{externalauth}/device [post]
func (api *API) postExternalAuthDeviceByID(rw http.ResponseWriter, r *http.Request) { func (api *API) postExternalAuthDeviceByID(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
apiKey := httpmw.APIKey(r) apiKey := httpmw.APIKey(r)
@ -169,7 +169,7 @@ func (api *API) postExternalAuthDeviceByID(rw http.ResponseWriter, r *http.Reque
// @Tags Git // @Tags Git
// @Param externalauth path string true "Git Provider ID" format(string) // @Param externalauth path string true "Git Provider ID" format(string)
// @Success 200 {object} codersdk.ExternalAuthDevice // @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) { func (*API) externalAuthDeviceByID(rw http.ResponseWriter, r *http.Request) {
config := httpmw.ExternalAuthParam(r) config := httpmw.ExternalAuthParam(r)
ctx := r.Context() ctx := r.Context()
@ -255,7 +255,7 @@ func (api *API) externalAuthCallback(externalAuthConfig *externalauth.Config) ht
redirect := state.Redirect redirect := state.Redirect
if redirect == "" { if redirect == "" {
// This is a nicely rendered screen on the frontend // 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) http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
} }

View File

@ -15,6 +15,7 @@ import (
"golang.org/x/xerrors" "golang.org/x/xerrors"
"github.com/google/go-github/v43/github" "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"
"github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/dbtime"
@ -35,9 +36,13 @@ type Config struct {
// ID is a unique identifier for the authenticator. // ID is a unique identifier for the authenticator.
ID string ID string
// Type is the type of provider. // Type is the type of provider.
Type codersdk.ExternalAuthProvider Type string
// DeviceAuth is set if the provider uses the device flow. // DeviceAuth is set if the provider uses the device flow.
DeviceAuth *DeviceAuth 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 // NoRefresh stops Coder from using the refresh token
// to renew the access token. // to renew the access token.
@ -113,7 +118,7 @@ validate:
// to the read replica in time. // to the read replica in time.
// //
// We do an exponential backoff here to give the write time to propagate. // 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 goto validate
} }
// The token is no longer valid! // 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 var user *codersdk.ExternalAuthUser
if c.Type == codersdk.ExternalAuthProviderGitHub { if c.Type == string(codersdk.EnhancedExternalAuthProviderGitHub) {
var ghUser github.User var ghUser github.User
err = json.NewDecoder(res.Body).Decode(&ghUser) err = json.NewDecoder(res.Body).Decode(&ghUser)
if err == nil { if err == nil {
@ -217,7 +222,7 @@ func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk
return nil, false, nil return nil, false, nil
} }
installs := []codersdk.ExternalAuthAppInstallation{} installs := []codersdk.ExternalAuthAppInstallation{}
if c.Type == codersdk.ExternalAuthProviderGitHub { if c.Type == string(codersdk.EnhancedExternalAuthProviderGitHub) {
var ghInstalls struct { var ghInstalls struct {
Installations []*github.Installation `json:"installations"` Installations []*github.Installation `json:"installations"`
} }
@ -245,50 +250,158 @@ func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk
return installs, true, nil 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 // ConvertConfig converts the SDK configuration entry format
// to the parsed and ready-to-consume in coderd provider type. // 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{}{} ids := map[string]struct{}{}
configs := []*Config{} configs := []*Config{}
for _, entry := range entries { for _, entry := range entries {
var typ codersdk.ExternalAuthProvider entry := entry
switch codersdk.ExternalAuthProvider(entry.Type) {
case codersdk.ExternalAuthProviderAzureDevops: // Applies defaults to the config entry.
typ = codersdk.ExternalAuthProviderAzureDevops // This allows users to very simply state that they type is "GitHub",
case codersdk.ExternalAuthProviderBitBucket: // apply their client secret and ID, and have the UI appear nicely.
typ = codersdk.ExternalAuthProviderBitBucket applyDefaultsToConfig(&entry)
case codersdk.ExternalAuthProviderGitHub:
typ = codersdk.ExternalAuthProviderGitHub valid := httpapi.NameValid(entry.ID)
case codersdk.ExternalAuthProviderGitLab: if valid != nil {
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 {
return nil, xerrors.Errorf("external auth provider %q doesn't have a valid id: %w", entry.ID, valid) 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] _, exists := ids[entry.ID]
if exists { if exists {
if entry.ID == string(typ) { if entry.ID == entry.Type {
return nil, xerrors.Errorf("multiple %s external auth providers provided. you must specify a unique id for each", typ) 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{}{} ids[entry.ID] = struct{}{}
if entry.ClientID == "" { authRedirect, err := accessURL.Parse(fmt.Sprintf("/external-auth/%s/callback", entry.ID))
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))
if err != nil { 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 != "" { if entry.Regex != "" {
regex, err = regexp.Compile(entry.Regex) regex, err = regexp.Compile(entry.Regex)
if err != nil { if err != nil {
@ -299,30 +412,17 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
oc := &oauth2.Config{ oc := &oauth2.Config{
ClientID: entry.ClientID, ClientID: entry.ClientID,
ClientSecret: entry.ClientSecret, ClientSecret: entry.ClientSecret,
Endpoint: endpoint[typ], Endpoint: oauth2.Endpoint{
RedirectURL: authRedirect.String(), AuthURL: entry.AuthURL,
Scopes: scope[typ], TokenURL: entry.TokenURL,
} },
RedirectURL: authRedirect.String(),
if entry.AuthURL != "" { Scopes: entry.Scopes,
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]
} }
var oauthConfig OAuth2Config = oc var oauthConfig OAuth2Config = oc
// Azure DevOps uses JWT token authentication! // Azure DevOps uses JWT token authentication!
if typ == codersdk.ExternalAuthProviderAzureDevops { if entry.Type == string(codersdk.EnhancedExternalAuthProviderAzureDevops) {
oauthConfig = &jwtConfig{oc} oauthConfig = &jwtConfig{oc}
} }
@ -330,17 +430,16 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
OAuth2Config: oauthConfig, OAuth2Config: oauthConfig,
ID: entry.ID, ID: entry.ID,
Regex: regex, Regex: regex,
Type: typ, Type: entry.Type,
NoRefresh: entry.NoRefresh, NoRefresh: entry.NoRefresh,
ValidateURL: entry.ValidateURL, ValidateURL: entry.ValidateURL,
AppInstallationsURL: entry.AppInstallationsURL, AppInstallationsURL: entry.AppInstallationsURL,
AppInstallURL: entry.AppInstallURL, AppInstallURL: entry.AppInstallURL,
DisplayName: entry.DisplayName,
DisplayIcon: entry.DisplayIcon,
} }
if entry.DeviceFlow { if entry.DeviceFlow {
if entry.DeviceCodeURL == "" {
entry.DeviceCodeURL = deviceAuthURL[typ]
}
if entry.DeviceCodeURL == "" { if entry.DeviceCodeURL == "" {
return nil, xerrors.Errorf("external auth provider %q: device auth url must be provided", entry.ID) 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 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) { 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) { 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) { GitConfigOpt: func(cfg *externalauth.Config) {
cfg.Type = codersdk.ExternalAuthProviderGitHub cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
}, },
DB: db, DB: db,
}) })
@ -266,42 +266,38 @@ func TestConvertYAML(t *testing.T) {
t.Parallel() t.Parallel()
for _, tc := range []struct { for _, tc := range []struct {
Name string Name string
Input []codersdk.GitAuthConfig Input []codersdk.ExternalAuthConfig
Output []*externalauth.Config Output []*externalauth.Config
Error string Error string
}{{ }{{
Name: "InvalidType",
Input: []codersdk.GitAuthConfig{{
Type: "moo",
}},
Error: "unknown git provider type",
}, {
Name: "InvalidID", Name: "InvalidID",
Input: []codersdk.GitAuthConfig{{ Input: []codersdk.ExternalAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitHub), Type: string(codersdk.EnhancedExternalAuthProviderGitHub),
ID: "$hi$", ID: "$hi$",
}}, }},
Error: "doesn't have a valid id", Error: "doesn't have a valid id",
}, { }, {
Name: "NoClientID", Name: "NoClientID",
Input: []codersdk.GitAuthConfig{{ Input: []codersdk.ExternalAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitHub), Type: string(codersdk.EnhancedExternalAuthProviderGitHub),
}}, }},
Error: "client_id must be provided", Error: "client_id must be provided",
}, { }, {
Name: "DuplicateType", Name: "DuplicateType",
Input: []codersdk.GitAuthConfig{{ Input: []codersdk.ExternalAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitHub), Type: string(codersdk.EnhancedExternalAuthProviderGitHub),
ClientID: "example", ClientID: "example",
ClientSecret: "example", ClientSecret: "example",
}, { }, {
Type: string(codersdk.ExternalAuthProviderGitHub), Type: string(codersdk.EnhancedExternalAuthProviderGitHub),
ClientID: "example-2",
ClientSecret: "example-2",
}}, }},
Error: "multiple github external auth providers provided", Error: "multiple github external auth providers provided",
}, { }, {
Name: "InvalidRegex", Name: "InvalidRegex",
Input: []codersdk.GitAuthConfig{{ Input: []codersdk.ExternalAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitHub), Type: string(codersdk.EnhancedExternalAuthProviderGitHub),
ClientID: "example", ClientID: "example",
ClientSecret: "example", ClientSecret: "example",
Regex: `\K`, Regex: `\K`,
@ -309,8 +305,8 @@ func TestConvertYAML(t *testing.T) {
Error: "compile regex for external auth provider", Error: "compile regex for external auth provider",
}, { }, {
Name: "NoDeviceURL", Name: "NoDeviceURL",
Input: []codersdk.GitAuthConfig{{ Input: []codersdk.ExternalAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitLab), Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
ClientID: "example", ClientID: "example",
ClientSecret: "example", ClientSecret: "example",
DeviceFlow: true, DeviceFlow: true,
@ -332,8 +328,8 @@ func TestConvertYAML(t *testing.T) {
t.Run("CustomScopesAndEndpoint", func(t *testing.T) { t.Run("CustomScopesAndEndpoint", func(t *testing.T) {
t.Parallel() t.Parallel()
config, err := externalauth.ConvertConfig([]codersdk.GitAuthConfig{{ config, err := externalauth.ConvertConfig([]codersdk.ExternalAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitLab), Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
ClientID: "id", ClientID: "id",
ClientSecret: "secret", ClientSecret: "secret",
AuthURL: "https://auth.com", AuthURL: "https://auth.com",
@ -341,7 +337,7 @@ func TestConvertYAML(t *testing.T) {
Scopes: []string{"read"}, Scopes: []string{"read"},
}}, &url.URL{}) }}, &url.URL{})
require.NoError(t, err) 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{{ ExternalAuthConfigs: []*externalauth.Config{{
ID: "test", ID: "test",
OAuth2Config: &testutil.OAuth2Config{}, OAuth2Config: &testutil.OAuth2Config{},
Type: codersdk.ExternalAuthProviderGitHub, Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}}, }},
}) })
coderdtest.CreateFirstUser(t, client) coderdtest.CreateFirstUser(t, client)
@ -51,7 +51,7 @@ func TestExternalAuthByID(t *testing.T) {
ID: "test", ID: "test",
OAuth2Config: &testutil.OAuth2Config{}, OAuth2Config: &testutil.OAuth2Config{},
// AzureDevops doesn't have a user endpoint! // AzureDevops doesn't have a user endpoint!
Type: codersdk.ExternalAuthProviderAzureDevops, Type: codersdk.EnhancedExternalAuthProviderAzureDevops.String(),
}}, }},
}) })
coderdtest.CreateFirstUser(t, client) coderdtest.CreateFirstUser(t, client)
@ -75,7 +75,7 @@ func TestExternalAuthByID(t *testing.T) {
ID: "test", ID: "test",
ValidateURL: validateSrv.URL, ValidateURL: validateSrv.URL,
OAuth2Config: &testutil.OAuth2Config{}, OAuth2Config: &testutil.OAuth2Config{},
Type: codersdk.ExternalAuthProviderGitHub, Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}}, }},
}) })
coderdtest.CreateFirstUser(t, client) coderdtest.CreateFirstUser(t, client)
@ -116,7 +116,7 @@ func TestExternalAuthByID(t *testing.T) {
ValidateURL: srv.URL + "/user", ValidateURL: srv.URL + "/user",
AppInstallationsURL: srv.URL + "/installs", AppInstallationsURL: srv.URL + "/installs",
OAuth2Config: &testutil.OAuth2Config{}, OAuth2Config: &testutil.OAuth2Config{},
Type: codersdk.ExternalAuthProviderGitHub, Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}}, }},
}) })
coderdtest.CreateFirstUser(t, client) coderdtest.CreateFirstUser(t, client)
@ -249,7 +249,7 @@ func TestGitAuthCallback(t *testing.T) {
OAuth2Config: &testutil.OAuth2Config{}, OAuth2Config: &testutil.OAuth2Config{},
ID: "github", ID: "github",
Regex: regexp.MustCompile(`github\.com`), Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub, Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}}, }},
}) })
user := coderdtest.CreateFirstUser(t, client) user := coderdtest.CreateFirstUser(t, client)
@ -268,7 +268,7 @@ func TestGitAuthCallback(t *testing.T) {
agentClient.SetSessionToken(authToken) agentClient.SetSessionToken(authToken)
token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false)
require.NoError(t, err) 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.Run("UnauthorizedCallback", func(t *testing.T) {
t.Parallel() t.Parallel()
@ -278,7 +278,7 @@ func TestGitAuthCallback(t *testing.T) {
OAuth2Config: &testutil.OAuth2Config{}, OAuth2Config: &testutil.OAuth2Config{},
ID: "github", ID: "github",
Regex: regexp.MustCompile(`github\.com`), Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub, Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}}, }},
}) })
resp := coderdtest.RequestExternalAuthCallback(t, "github", client) resp := coderdtest.RequestExternalAuthCallback(t, "github", client)
@ -292,7 +292,7 @@ func TestGitAuthCallback(t *testing.T) {
OAuth2Config: &testutil.OAuth2Config{}, OAuth2Config: &testutil.OAuth2Config{},
ID: "github", ID: "github",
Regex: regexp.MustCompile(`github\.com`), Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub, Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}}, }},
}) })
_ = coderdtest.CreateFirstUser(t, client) _ = coderdtest.CreateFirstUser(t, client)
@ -300,7 +300,7 @@ func TestGitAuthCallback(t *testing.T) {
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
location, err := resp.Location() location, err := resp.Location()
require.NoError(t, err) 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. // Callback again to simulate updating the token.
resp = coderdtest.RequestExternalAuthCallback(t, "github", client) resp = coderdtest.RequestExternalAuthCallback(t, "github", client)
@ -319,7 +319,7 @@ func TestGitAuthCallback(t *testing.T) {
OAuth2Config: &testutil.OAuth2Config{}, OAuth2Config: &testutil.OAuth2Config{},
ID: "github", ID: "github",
Regex: regexp.MustCompile(`github\.com`), Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub, Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}}, }},
}) })
user := coderdtest.CreateFirstUser(t, client) user := coderdtest.CreateFirstUser(t, client)
@ -376,7 +376,7 @@ func TestGitAuthCallback(t *testing.T) {
}, },
ID: "github", ID: "github",
Regex: regexp.MustCompile(`github\.com`), Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub, Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
NoRefresh: true, NoRefresh: true,
}}, }},
}) })
@ -420,7 +420,7 @@ func TestGitAuthCallback(t *testing.T) {
OAuth2Config: &testutil.OAuth2Config{}, OAuth2Config: &testutil.OAuth2Config{},
ID: "github", ID: "github",
Regex: regexp.MustCompile(`github\.com`), Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub, Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}}, }},
}) })
user := coderdtest.CreateFirstUser(t, client) user := coderdtest.CreateFirstUser(t, client)

View File

@ -65,9 +65,9 @@ func Test_RoutePatterns(t *testing.T) {
"/api/**", "/api/**",
"/@*/*/apps/**", "/@*/*/apps/**",
"/%40*/*/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", name: "Slash",

View File

@ -280,7 +280,7 @@ func (api *API) templateVersionRichParameters(rw http.ResponseWriter, r *http.Re
// @Tags Templates // @Tags Templates
// @Param templateversion path string true "Template version ID" format(uuid) // @Param templateversion path string true "Template version ID" format(uuid)
// @Success 200 {array} codersdk.TemplateVersionExternalAuth // @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) { func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
var ( 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. // 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 { if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to parse access URL.", Message: "Failed to parse access URL.",
@ -320,6 +320,8 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ
ID: config.ID, ID: config.ID,
Type: config.Type, Type: config.Type,
AuthenticateURL: redirectURL.String(), AuthenticateURL: redirectURL.String(),
DisplayName: config.DisplayName,
DisplayIcon: config.DisplayIcon,
} }
authLink, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{ authLink, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{

View File

@ -342,7 +342,7 @@ func TestTemplateVersionsExternalAuth(t *testing.T) {
OAuth2Config: &testutil.OAuth2Config{}, OAuth2Config: &testutil.OAuth2Config{},
ID: "github", ID: "github",
Regex: regexp.MustCompile(`github\.com`), Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub, Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}}, }},
}) })
user := coderdtest.CreateFirstUser(t, client) user := coderdtest.CreateFirstUser(t, client)

View File

@ -26,7 +26,7 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han
"/api/**", "/api/**",
"/@*/*/apps/**", "/@*/*/apps/**",
"/%40*/*/apps/**", "/%40*/*/apps/**",
"/externalauth/*/callback", "/external-auth/*/callback",
}.MustCompile() }.MustCompile()
var tracer trace.Tracer 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", true},
{"/%40hi/hi/apps/hi/hi", true}, {"/%40hi/hi/apps/hi/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. // Other routes that should not be collected.
{"/index.html", false}, {"/index.html", false},

View File

@ -2201,6 +2201,13 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
}) })
return 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) workspaceAgent := httpmw.WorkspaceAgent(r)
// We must get the workspace to get the owner ID! // We must get the workspace to get the owner ID!
resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID) resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID)
@ -2272,13 +2279,13 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
if !valid { if !valid {
continue continue
} }
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken)) httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(enhancedType, externalAuthLink.OAuthAccessToken))
return return
} }
} }
// This is the URL that will redirect the user with a state token. // 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 { if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to parse access URL.", Message: "Failed to parse access URL.",
@ -2320,20 +2327,20 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
}) })
return 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. // 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 var resp agentsdk.GitAuthResponse
switch typ { switch typ {
case codersdk.ExternalAuthProviderGitLab: case codersdk.EnhancedExternalAuthProviderGitLab:
// https://stackoverflow.com/questions/25409700/using-gitlab-token-to-clone-without-authentication // https://stackoverflow.com/questions/25409700/using-gitlab-token-to-clone-without-authentication
resp = agentsdk.GitAuthResponse{ resp = agentsdk.GitAuthResponse{
Username: "oauth2", Username: "oauth2",
Password: token, 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 // https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/#Cloning-a-repository-with-an-access-token
resp = agentsdk.GitAuthResponse{ resp = agentsdk.GitAuthResponse{
Username: "x-token-auth", Username: "x-token-auth",

View File

@ -134,52 +134,52 @@ type DeploymentValues struct {
DocsURL clibase.URL `json:"docs_url,omitempty"` DocsURL clibase.URL `json:"docs_url,omitempty"`
RedirectToAccessURL clibase.Bool `json:"redirect_to_access_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 is a string because it may be set to zero to disable.
HTTPAddress clibase.String `json:"http_address,omitempty" typescript:",notnull"` HTTPAddress clibase.String `json:"http_address,omitempty" typescript:",notnull"`
AutobuildPollInterval clibase.Duration `json:"autobuild_poll_interval,omitempty"` AutobuildPollInterval clibase.Duration `json:"autobuild_poll_interval,omitempty"`
JobHangDetectorInterval clibase.Duration `json:"job_hang_detector_interval,omitempty"` JobHangDetectorInterval clibase.Duration `json:"job_hang_detector_interval,omitempty"`
DERP DERP `json:"derp,omitempty" typescript:",notnull"` DERP DERP `json:"derp,omitempty" typescript:",notnull"`
Prometheus PrometheusConfig `json:"prometheus,omitempty" typescript:",notnull"` Prometheus PrometheusConfig `json:"prometheus,omitempty" typescript:",notnull"`
Pprof PprofConfig `json:"pprof,omitempty" typescript:",notnull"` Pprof PprofConfig `json:"pprof,omitempty" typescript:",notnull"`
ProxyTrustedHeaders clibase.StringArray `json:"proxy_trusted_headers,omitempty" typescript:",notnull"` ProxyTrustedHeaders clibase.StringArray `json:"proxy_trusted_headers,omitempty" typescript:",notnull"`
ProxyTrustedOrigins clibase.StringArray `json:"proxy_trusted_origins,omitempty" typescript:",notnull"` ProxyTrustedOrigins clibase.StringArray `json:"proxy_trusted_origins,omitempty" typescript:",notnull"`
CacheDir clibase.String `json:"cache_directory,omitempty" typescript:",notnull"` CacheDir clibase.String `json:"cache_directory,omitempty" typescript:",notnull"`
InMemoryDatabase clibase.Bool `json:"in_memory_database,omitempty" typescript:",notnull"` InMemoryDatabase clibase.Bool `json:"in_memory_database,omitempty" typescript:",notnull"`
PostgresURL clibase.String `json:"pg_connection_url,omitempty" typescript:",notnull"` PostgresURL clibase.String `json:"pg_connection_url,omitempty" typescript:",notnull"`
OAuth2 OAuth2Config `json:"oauth2,omitempty" typescript:",notnull"` OAuth2 OAuth2Config `json:"oauth2,omitempty" typescript:",notnull"`
OIDC OIDCConfig `json:"oidc,omitempty" typescript:",notnull"` OIDC OIDCConfig `json:"oidc,omitempty" typescript:",notnull"`
Telemetry TelemetryConfig `json:"telemetry,omitempty" typescript:",notnull"` Telemetry TelemetryConfig `json:"telemetry,omitempty" typescript:",notnull"`
TLS TLSConfig `json:"tls,omitempty" typescript:",notnull"` TLS TLSConfig `json:"tls,omitempty" typescript:",notnull"`
Trace TraceConfig `json:"trace,omitempty" typescript:",notnull"` Trace TraceConfig `json:"trace,omitempty" typescript:",notnull"`
SecureAuthCookie clibase.Bool `json:"secure_auth_cookie,omitempty" typescript:",notnull"` SecureAuthCookie clibase.Bool `json:"secure_auth_cookie,omitempty" typescript:",notnull"`
StrictTransportSecurity clibase.Int64 `json:"strict_transport_security,omitempty" typescript:",notnull"` StrictTransportSecurity clibase.Int64 `json:"strict_transport_security,omitempty" typescript:",notnull"`
StrictTransportSecurityOptions clibase.StringArray `json:"strict_transport_security_options,omitempty" typescript:",notnull"` StrictTransportSecurityOptions clibase.StringArray `json:"strict_transport_security_options,omitempty" typescript:",notnull"`
SSHKeygenAlgorithm clibase.String `json:"ssh_keygen_algorithm,omitempty" typescript:",notnull"` SSHKeygenAlgorithm clibase.String `json:"ssh_keygen_algorithm,omitempty" typescript:",notnull"`
MetricsCacheRefreshInterval clibase.Duration `json:"metrics_cache_refresh_interval,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"` AgentStatRefreshInterval clibase.Duration `json:"agent_stat_refresh_interval,omitempty" typescript:",notnull"`
AgentFallbackTroubleshootingURL clibase.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"` AgentFallbackTroubleshootingURL clibase.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"`
BrowserOnly clibase.Bool `json:"browser_only,omitempty" typescript:",notnull"` BrowserOnly clibase.Bool `json:"browser_only,omitempty" typescript:",notnull"`
SCIMAPIKey clibase.String `json:"scim_api_key,omitempty" typescript:",notnull"` SCIMAPIKey clibase.String `json:"scim_api_key,omitempty" typescript:",notnull"`
ExternalTokenEncryptionKeys clibase.StringArray `json:"external_token_encryption_keys,omitempty" typescript:",notnull"` ExternalTokenEncryptionKeys clibase.StringArray `json:"external_token_encryption_keys,omitempty" typescript:",notnull"`
Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"` Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"`
RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"` RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"`
Experiments clibase.StringArray `json:"experiments,omitempty" typescript:",notnull"` Experiments clibase.StringArray `json:"experiments,omitempty" typescript:",notnull"`
UpdateCheck clibase.Bool `json:"update_check,omitempty" typescript:",notnull"` UpdateCheck clibase.Bool `json:"update_check,omitempty" typescript:",notnull"`
MaxTokenLifetime clibase.Duration `json:"max_token_lifetime,omitempty" typescript:",notnull"` MaxTokenLifetime clibase.Duration `json:"max_token_lifetime,omitempty" typescript:",notnull"`
Swagger SwaggerConfig `json:"swagger,omitempty" typescript:",notnull"` Swagger SwaggerConfig `json:"swagger,omitempty" typescript:",notnull"`
Logging LoggingConfig `json:"logging,omitempty" typescript:",notnull"` Logging LoggingConfig `json:"logging,omitempty" typescript:",notnull"`
Dangerous DangerousConfig `json:"dangerous,omitempty" typescript:",notnull"` Dangerous DangerousConfig `json:"dangerous,omitempty" typescript:",notnull"`
DisablePathApps clibase.Bool `json:"disable_path_apps,omitempty" typescript:",notnull"` DisablePathApps clibase.Bool `json:"disable_path_apps,omitempty" typescript:",notnull"`
SessionDuration clibase.Duration `json:"max_session_expiry,omitempty" typescript:",notnull"` SessionDuration clibase.Duration `json:"max_session_expiry,omitempty" typescript:",notnull"`
DisableSessionExpiryRefresh clibase.Bool `json:"disable_session_expiry_refresh,omitempty" typescript:",notnull"` DisableSessionExpiryRefresh clibase.Bool `json:"disable_session_expiry_refresh,omitempty" typescript:",notnull"`
DisablePasswordAuth clibase.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"` DisablePasswordAuth clibase.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"`
Support SupportConfig `json:"support,omitempty" typescript:",notnull"` Support SupportConfig `json:"support,omitempty" typescript:",notnull"`
GitAuthProviders clibase.Struct[[]GitAuthConfig] `json:"git_auth,omitempty" typescript:",notnull"` ExternalAuthConfigs clibase.Struct[[]ExternalAuthConfig] `json:"external_auth,omitempty" typescript:",notnull"`
SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"`
WgtunnelHost clibase.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` WgtunnelHost clibase.String `json:"wgtunnel_host,omitempty" typescript:",notnull"`
DisableOwnerWorkspaceExec clibase.Bool `json:"disable_owner_workspace_exec,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"` ProxyHealthStatusInterval clibase.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"`
EnableTerraformDebugMode clibase.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"` EnableTerraformDebugMode clibase.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"`
UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"`
Config clibase.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"` Config clibase.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"`
WriteConfig clibase.Bool `json:"write_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"` 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"` ID string `json:"id"`
Type string `json:"type"`
ClientID string `json:"client_id"`
ClientSecret string `json:"-" yaml:"client_secret"`
AuthURL string `json:"auth_url"` AuthURL string `json:"auth_url"`
TokenURL string `json:"token_url"` TokenURL string `json:"token_url"`
ValidateURL string `json:"validate_url"` ValidateURL string `json:"validate_url"`
AppInstallURL string `json:"app_install_url"` AppInstallURL string `json:"app_install_url"`
AppInstallationsURL string `json:"app_installations_url"` AppInstallationsURL string `json:"app_installations_url"`
Regex string `json:"regex"`
NoRefresh bool `json:"no_refresh"` NoRefresh bool `json:"no_refresh"`
Scopes []string `json:"scopes"` Scopes []string `json:"scopes"`
DeviceFlow bool `json:"device_flow"` DeviceFlow bool `json:"device_flow"`
DeviceCodeURL string `json:"device_code_url"` 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 { type ProvisionerConfig struct {
@ -1710,12 +1723,12 @@ Write out the current server config as YAML to stdout.`,
}, },
{ {
// Env handling is done in cli.ReadGitAuthFromEnvironment // Env handling is done in cli.ReadGitAuthFromEnvironment
Name: "Git Auth Providers", Name: "External Auth Providers",
Description: "Git Authentication providers.", Description: "External Authentication providers.",
// We need extra scrutiny to ensure this works, is documented, and // We need extra scrutiny to ensure this works, is documented, and
// tested before enabling. // tested before enabling.
// YAML: "gitAuthProviders", // YAML: "gitAuthProviders",
Value: &c.GitAuthProviders, Value: &c.ExternalAuthConfigs,
Hidden: true, Hidden: true,
}, },
{ {

View File

@ -65,9 +65,9 @@ func TestDeploymentValues_HighlyConfigurable(t *testing.T) {
flag: true, flag: true,
env: true, env: true,
}, },
"Git Auth Providers": { "External Auth Providers": {
// Technically Git Auth Providers can be provided through the env, // Technically External Auth Providers can be provided through the env,
// but bypassing clibase. See cli.ReadGitAuthProvidersFromEnv. // but bypassing clibase. See cli.ReadExternalAuthProvidersFromEnv.
flag: true, flag: true,
env: true, env: true,
}, },

View File

@ -7,10 +7,39 @@ import (
"net/http" "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 { type ExternalAuth struct {
Authenticated bool `json:"authenticated"` Authenticated bool `json:"authenticated"`
Device bool `json:"device"` Device bool `json:"device"`
Type string `json:"type"` DisplayName string `json:"display_name"`
// User is the user that authenticated with the provider. // User is the user that authenticated with the provider.
User *ExternalAuthUser `json:"user"` User *ExternalAuthUser `json:"user"`
@ -50,7 +79,7 @@ type ExternalAuthDeviceExchange struct {
} }
func (c *Client) ExternalAuthDeviceByID(ctx context.Context, provider string) (ExternalAuthDevice, error) { 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 { if err != nil {
return ExternalAuthDevice{}, err 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. // ExchangeGitAuth exchanges a device code for an external auth token.
func (c *Client) ExternalAuthDeviceExchange(ctx context.Context, provider string, req ExternalAuthDeviceExchange) error { 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 { if err != nil {
return err 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. // ExternalAuthByID returns the external auth for the given provider by ID.
func (c *Client) ExternalAuthByID(ctx context.Context, provider string) (ExternalAuth, error) { 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 { if err != nil {
return ExternalAuth{}, err return ExternalAuth{}, err
} }

View File

@ -34,10 +34,12 @@ type TemplateVersion struct {
} }
type TemplateVersionExternalAuth struct { type TemplateVersionExternalAuth struct {
ID string `json:"id"` ID string `json:"id"`
Type ExternalAuthProvider `json:"type"` Type string `json:"type"`
AuthenticateURL string `json:"authenticate_url"` DisplayName string `json:"display_name"`
Authenticated bool `json:"authenticated"` DisplayIcon string `json:"display_icon"`
AuthenticateURL string `json:"authenticate_url"`
Authenticated bool `json:"authenticated"`
} }
type ValidationMonotonicOrder string 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. // TemplateVersionExternalAuth returns authentication providers for the requested template version.
func (c *Client) TemplateVersionExternalAuth(ctx context.Context, version uuid.UUID) ([]TemplateVersionExternalAuth, error) { 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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -744,35 +744,6 @@ func (c *Client) WorkspaceAgentLogsAfter(ctx context.Context, agentID uuid.UUID,
}), nil }), 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 { type WorkspaceAgentLog struct {
ID int64 `json:"id"` ID int64 `json:"id"`
CreatedAt time.Time `json:"created_at" format:"date-time"` 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 Coder integrates with Git and OpenID Connect to automate away the need for
authenticate with repositories within their workspace. 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 When developers use `git` inside their workspace, they are prompted to
authenticate. After that, Coder will store and refresh tokens for future authenticate. After that, Coder will store and refresh tokens for future
operations. operations.
<video autoplay playsinline loop> <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. Your browser does not support the video tag.
</video> </video>
## Configuration ## Configuration
To add a git provider, you'll need to create an OAuth application. The following To add an external authentication provider, you'll need to create an OAuth
providers are supported: application. The following providers are supported:
- [GitHub](#github-app) - [GitHub](#github)
- [GitLab](https://docs.gitlab.com/ee/integration/oauth_provider.html) - [GitLab](https://docs.gitlab.com/ee/integration/oauth_provider.html)
- [BitBucket](https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/) - [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) - [Azure DevOps](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=azure-devops)
Example callback URL: Example callback URL:
`https://coder.example.com/gitauth/primary-github/callback`. Use an arbitrary ID `https://coder.example.com/external-auth/primary-github/callback`. Use an
for your provider (e.g. `primary-github`). arbitrary ID for your provider (e.g. `primary-github`).
Set the following environment variables to Set the following environment variables to
[configure the Coder server](./configure.md): [configure the Coder server](./configure.md):
```env ```env
CODER_GITAUTH_0_ID="primary-github" CODER_EXTERNAL_AUTH_0_ID="primary-github"
CODER_GITAUTH_0_TYPE=github|gitlab|azure-devops|bitbucket CODER_EXTERNAL_AUTH_0_TYPE=github|gitlab|azure-devops|bitbucket|<name of service e.g. jfrog>
CODER_GITAUTH_0_CLIENT_ID=xxxxxx CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx
CODER_GITAUTH_0_CLIENT_SECRET=xxxxxxx 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 ### GitHub
@ -69,9 +73,9 @@ CODER_GITAUTH_0_CLIENT_SECRET=xxxxxxx
GitHub Enterprise requires the following authentication and token URLs: GitHub Enterprise requires the following authentication and token URLs:
```env ```env
CODER_GITAUTH_0_VALIDATE_URL="https://github.example.com/login/oauth/access_token/info" CODER_EXTERNAL_AUTH_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_EXTERNAL_AUTH_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_TOKEN_URL="https://github.example.com/login/oauth/access_token"
``` ```
### Azure DevOps ### 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: Azure DevOps requires the following environment variables:
```env ```env
CODER_GITAUTH_0_ID="primary-azure-devops" CODER_EXTERNAL_AUTH_0_ID="primary-azure-devops"
CODER_GITAUTH_0_TYPE=azure-devops CODER_EXTERNAL_AUTH_0_TYPE=azure-devops
CODER_GITAUTH_0_CLIENT_ID=xxxxxx CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx
# Ensure this value is your "Client Secret", not "App Secret" # Ensure this value is your "Client Secret", not "App Secret"
CODER_GITAUTH_0_CLIENT_SECRET=xxxxxxx CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx
CODER_GITAUTH_0_AUTH_URL="https://app.vssps.visualstudio.com/oauth2/authorize" CODER_EXTERNAL_AUTH_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_TOKEN_URL="https://app.vssps.visualstudio.com/oauth2/token"
``` ```
### Self-managed git providers ### Self-managed git providers
@ -94,9 +98,9 @@ Custom authentication and token URLs should be used for self-managed Git
provider deployments. provider deployments.
```env ```env
CODER_GITAUTH_0_AUTH_URL="https://github.example.com/oauth/authorize" CODER_EXTERNAL_AUTH_0_AUTH_URL="https://github.example.com/oauth/authorize"
CODER_GITAUTH_0_TOKEN_URL="https://github.example.com/oauth/token" CODER_EXTERNAL_AUTH_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_VALIDATE_URL="https://your-domain.com/oauth/token/info"
``` ```
### Custom scopes ### Custom scopes
@ -104,7 +108,7 @@ CODER_GITAUTH_0_VALIDATE_URL="https://your-domain.com/oauth/token/info"
Optionally, you can request custom scopes: Optionally, you can request custom scopes:
```env ```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) ### Multiple git providers (enterprise)
@ -116,21 +120,21 @@ limit auth scope. Here's a sample config:
```env ```env
# Provider 1) github.com # Provider 1) github.com
CODER_GITAUTH_0_ID=primary-github CODER_EXTERNAL_AUTH_0_ID=primary-github
CODER_GITAUTH_0_TYPE=github CODER_EXTERNAL_AUTH_0_TYPE=github
CODER_GITAUTH_0_CLIENT_ID=xxxxxx CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx
CODER_GITAUTH_0_CLIENT_SECRET=xxxxxxx CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx
CODER_GITAUTH_0_REGEX=github.com/orgname CODER_EXTERNAL_AUTH_0_REGEX=github.com/orgname
# Provider 2) github.example.com # Provider 2) github.example.com
CODER_GITAUTH_1_ID=secondary-github CODER_EXTERNAL_AUTH_1_ID=secondary-github
CODER_GITAUTH_1_TYPE=github CODER_EXTERNAL_AUTH_1_TYPE=github
CODER_GITAUTH_1_CLIENT_ID=xxxxxx CODER_EXTERNAL_AUTH_1_CLIENT_ID=xxxxxx
CODER_GITAUTH_1_CLIENT_SECRET=xxxxxxx CODER_EXTERNAL_AUTH_1_CLIENT_SECRET=xxxxxxx
CODER_GITAUTH_1_REGEX=github.example.com CODER_EXTERNAL_AUTH_1_REGEX=github.example.com
CODER_GITAUTH_1_AUTH_URL="https://github.example.com/login/oauth/authorize" CODER_EXTERNAL_AUTH_1_AUTH_URL="https://github.example.com/login/oauth/authorize"
CODER_GITAUTH_1_TOKEN_URL="https://github.example.com/login/oauth/access_token" CODER_EXTERNAL_AUTH_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_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 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, "enable_terraform_debug_mode": true,
"experiments": ["string"], "experiments": ["string"],
"external_token_encryption_keys": ["string"], "external_auth": {
"git_auth": {
"value": [ "value": [
{ {
"app_install_url": "string", "app_install_url": "string",
@ -222,6 +221,8 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"client_id": "string", "client_id": "string",
"device_code_url": "string", "device_code_url": "string",
"device_flow": true, "device_flow": true,
"display_icon": "string",
"display_name": "string",
"id": "string", "id": "string",
"no_refresh": true, "no_refresh": true,
"regex": "string", "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", "http_address": "string",
"in_memory_database": true, "in_memory_database": true,
"job_hang_detector_interval": 0, "job_hang_detector_interval": 0,

14
docs/api/git.md generated
View File

@ -6,12 +6,12 @@
```shell ```shell
# Example request using curl # 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 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY' -H 'Coder-Session-Token: API_KEY'
``` ```
`GET /externalauth/{externalauth}` `GET /external-auth/{externalauth}`
### Parameters ### Parameters
@ -29,6 +29,7 @@ curl -X GET http://coder-server:8080/api/v2/externalauth/{externalauth} \
"app_installable": true, "app_installable": true,
"authenticated": true, "authenticated": true,
"device": true, "device": true,
"display_name": "string",
"installations": [ "installations": [
{ {
"account": { "account": {
@ -41,7 +42,6 @@ curl -X GET http://coder-server:8080/api/v2/externalauth/{externalauth} \
"id": 0 "id": 0
} }
], ],
"type": "string",
"user": { "user": {
"avatar_url": "string", "avatar_url": "string",
"login": "string", "login": "string",
@ -65,12 +65,12 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio
```shell ```shell
# Example request using curl # 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 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY' -H 'Coder-Session-Token: API_KEY'
``` ```
`GET /externalauth/{externalauth}/device` `GET /external-auth/{externalauth}/device`
### Parameters ### Parameters
@ -106,11 +106,11 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio
```shell ```shell
# Example request using curl # 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' -H 'Coder-Session-Token: API_KEY'
``` ```
`POST /externalauth/{externalauth}/device` `POST /external-auth/{externalauth}/device`
### Parameters ### Parameters

255
docs/api/schemas.md generated
View File

@ -620,7 +620,7 @@
_None_ _None_
## clibase.Struct-array_codersdk_GitAuthConfig ## clibase.Struct-array_codersdk_ExternalAuthConfig
```json ```json
{ {
@ -632,6 +632,8 @@ _None_
"client_id": "string", "client_id": "string",
"device_code_url": "string", "device_code_url": "string",
"device_flow": true, "device_flow": true,
"display_icon": "string",
"display_name": "string",
"id": "string", "id": "string",
"no_refresh": true, "no_refresh": true,
"regex": "string", "regex": "string",
@ -646,9 +648,9 @@ _None_
### Properties ### Properties
| Name | Type | Required | Restrictions | Description | | Name | Type | Required | Restrictions | Description |
| ------- | --------------------------------------------------------- | -------- | ------------ | ----------- | | ------- | ------------------------------------------------------------------- | -------- | ------------ | ----------- |
| `value` | array of [codersdk.GitAuthConfig](#codersdkgitauthconfig) | false | | | | `value` | array of [codersdk.ExternalAuthConfig](#codersdkexternalauthconfig) | false | | |
## clibase.Struct-array_codersdk_LinkConfig ## 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, "enable_terraform_debug_mode": true,
"experiments": ["string"], "experiments": ["string"],
"external_token_encryption_keys": ["string"], "external_auth": {
"git_auth": {
"value": [ "value": [
{ {
"app_install_url": "string", "app_install_url": "string",
@ -2053,6 +2054,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"client_id": "string", "client_id": "string",
"device_code_url": "string", "device_code_url": "string",
"device_flow": true, "device_flow": true,
"display_icon": "string",
"display_name": "string",
"id": "string", "id": "string",
"no_refresh": true, "no_refresh": true,
"regex": "string", "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", "http_address": "string",
"in_memory_database": true, "in_memory_database": true,
"job_hang_detector_interval": 0, "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, "enable_terraform_debug_mode": true,
"experiments": ["string"], "experiments": ["string"],
"external_token_encryption_keys": ["string"], "external_auth": {
"git_auth": {
"value": [ "value": [
{ {
"app_install_url": "string", "app_install_url": "string",
@ -2418,6 +2421,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"client_id": "string", "client_id": "string",
"device_code_url": "string", "device_code_url": "string",
"device_flow": true, "device_flow": true,
"display_icon": "string",
"display_name": "string",
"id": "string", "id": "string",
"no_refresh": true, "no_refresh": true,
"regex": "string", "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", "http_address": "string",
"in_memory_database": true, "in_memory_database": true,
"job_hang_detector_interval": 0, "job_hang_detector_interval": 0,
@ -2602,62 +2608,62 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
### Properties ### Properties
| Name | Type | Required | Restrictions | Description | | Name | Type | Required | Restrictions | Description |
| ------------------------------------ | ------------------------------------------------------------------------------------------ | -------- | ------------ | ------------------------------------------------------------------ | | ------------------------------------ | ---------------------------------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------ |
| `access_url` | [clibase.URL](#clibaseurl) | false | | | | `access_url` | [clibase.URL](#clibaseurl) | false | | |
| `address` | [clibase.HostPort](#clibasehostport) | false | | Address Use HTTPAddress or TLS.Address instead. | | `address` | [clibase.HostPort](#clibasehostport) | false | | Address Use HTTPAddress or TLS.Address instead. |
| `agent_fallback_troubleshooting_url` | [clibase.URL](#clibaseurl) | false | | | | `agent_fallback_troubleshooting_url` | [clibase.URL](#clibaseurl) | false | | |
| `agent_stat_refresh_interval` | integer | false | | | | `agent_stat_refresh_interval` | integer | false | | |
| `autobuild_poll_interval` | integer | false | | | | `autobuild_poll_interval` | integer | false | | |
| `browser_only` | boolean | false | | | | `browser_only` | boolean | false | | |
| `cache_directory` | string | false | | | | `cache_directory` | string | false | | |
| `config` | string | false | | | | `config` | string | false | | |
| `config_ssh` | [codersdk.SSHConfig](#codersdksshconfig) | false | | | | `config_ssh` | [codersdk.SSHConfig](#codersdksshconfig) | false | | |
| `dangerous` | [codersdk.DangerousConfig](#codersdkdangerousconfig) | false | | | | `dangerous` | [codersdk.DangerousConfig](#codersdkdangerousconfig) | false | | |
| `derp` | [codersdk.DERP](#codersdkderp) | false | | | | `derp` | [codersdk.DERP](#codersdkderp) | false | | |
| `disable_owner_workspace_exec` | boolean | false | | | | `disable_owner_workspace_exec` | boolean | false | | |
| `disable_password_auth` | boolean | false | | | | `disable_password_auth` | boolean | false | | |
| `disable_path_apps` | boolean | false | | | | `disable_path_apps` | boolean | false | | |
| `disable_session_expiry_refresh` | boolean | false | | | | `disable_session_expiry_refresh` | boolean | false | | |
| `docs_url` | [clibase.URL](#clibaseurl) | false | | | | `docs_url` | [clibase.URL](#clibaseurl) | false | | |
| `enable_terraform_debug_mode` | boolean | false | | | | `enable_terraform_debug_mode` | boolean | false | | |
| `experiments` | array of string | false | | | | `experiments` | array of string | false | | |
| `external_token_encryption_keys` | array of string | false | | | | `external_auth` | [clibase.Struct-array_codersdk_ExternalAuthConfig](#clibasestruct-array_codersdk_externalauthconfig) | false | | |
| `git_auth` | [clibase.Struct-array_codersdk_GitAuthConfig](#clibasestruct-array_codersdk_gitauthconfig) | 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. | | `http_address` | string | false | | Http address is a string because it may be set to zero to disable. |
| `in_memory_database` | boolean | false | | | | `in_memory_database` | boolean | false | | |
| `job_hang_detector_interval` | integer | false | | | | `job_hang_detector_interval` | integer | false | | |
| `logging` | [codersdk.LoggingConfig](#codersdkloggingconfig) | false | | | | `logging` | [codersdk.LoggingConfig](#codersdkloggingconfig) | false | | |
| `max_session_expiry` | integer | false | | | | `max_session_expiry` | integer | false | | |
| `max_token_lifetime` | integer | false | | | | `max_token_lifetime` | integer | false | | |
| `metrics_cache_refresh_interval` | integer | false | | | | `metrics_cache_refresh_interval` | integer | false | | |
| `oauth2` | [codersdk.OAuth2Config](#codersdkoauth2config) | false | | | | `oauth2` | [codersdk.OAuth2Config](#codersdkoauth2config) | false | | |
| `oidc` | [codersdk.OIDCConfig](#codersdkoidcconfig) | false | | | | `oidc` | [codersdk.OIDCConfig](#codersdkoidcconfig) | false | | |
| `pg_connection_url` | string | false | | | | `pg_connection_url` | string | false | | |
| `pprof` | [codersdk.PprofConfig](#codersdkpprofconfig) | false | | | | `pprof` | [codersdk.PprofConfig](#codersdkpprofconfig) | false | | |
| `prometheus` | [codersdk.PrometheusConfig](#codersdkprometheusconfig) | false | | | | `prometheus` | [codersdk.PrometheusConfig](#codersdkprometheusconfig) | false | | |
| `provisioner` | [codersdk.ProvisionerConfig](#codersdkprovisionerconfig) | false | | | | `provisioner` | [codersdk.ProvisionerConfig](#codersdkprovisionerconfig) | false | | |
| `proxy_health_status_interval` | integer | false | | | | `proxy_health_status_interval` | integer | false | | |
| `proxy_trusted_headers` | array of string | false | | | | `proxy_trusted_headers` | array of string | false | | |
| `proxy_trusted_origins` | array of string | false | | | | `proxy_trusted_origins` | array of string | false | | |
| `rate_limit` | [codersdk.RateLimitConfig](#codersdkratelimitconfig) | false | | | | `rate_limit` | [codersdk.RateLimitConfig](#codersdkratelimitconfig) | false | | |
| `redirect_to_access_url` | boolean | false | | | | `redirect_to_access_url` | boolean | false | | |
| `scim_api_key` | string | false | | | | `scim_api_key` | string | false | | |
| `secure_auth_cookie` | boolean | false | | | | `secure_auth_cookie` | boolean | false | | |
| `ssh_keygen_algorithm` | string | false | | | | `ssh_keygen_algorithm` | string | false | | |
| `strict_transport_security` | integer | false | | | | `strict_transport_security` | integer | false | | |
| `strict_transport_security_options` | array of string | false | | | | `strict_transport_security_options` | array of string | false | | |
| `support` | [codersdk.SupportConfig](#codersdksupportconfig) | false | | | | `support` | [codersdk.SupportConfig](#codersdksupportconfig) | false | | |
| `swagger` | [codersdk.SwaggerConfig](#codersdkswaggerconfig) | false | | | | `swagger` | [codersdk.SwaggerConfig](#codersdkswaggerconfig) | false | | |
| `telemetry` | [codersdk.TelemetryConfig](#codersdktelemetryconfig) | false | | | | `telemetry` | [codersdk.TelemetryConfig](#codersdktelemetryconfig) | false | | |
| `tls` | [codersdk.TLSConfig](#codersdktlsconfig) | false | | | | `tls` | [codersdk.TLSConfig](#codersdktlsconfig) | false | | |
| `trace` | [codersdk.TraceConfig](#codersdktraceconfig) | false | | | | `trace` | [codersdk.TraceConfig](#codersdktraceconfig) | false | | |
| `update_check` | boolean | false | | | | `update_check` | boolean | false | | |
| `user_quiet_hours_schedule` | [codersdk.UserQuietHoursScheduleConfig](#codersdkuserquiethoursscheduleconfig) | false | | | | `user_quiet_hours_schedule` | [codersdk.UserQuietHoursScheduleConfig](#codersdkuserquiethoursscheduleconfig) | false | | |
| `verbose` | boolean | false | | | | `verbose` | boolean | false | | |
| `wgtunnel_host` | string | false | | | | `wgtunnel_host` | string | false | | |
| `wildcard_access_url` | [clibase.URL](#clibaseurl) | false | | | | `wildcard_access_url` | [clibase.URL](#clibaseurl) | false | | |
| `write_config` | boolean | false | | | | `write_config` | boolean | false | | |
## codersdk.DisplayApp ## codersdk.DisplayApp
@ -2760,6 +2766,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"app_installable": true, "app_installable": true,
"authenticated": true, "authenticated": true,
"device": true, "device": true,
"display_name": "string",
"installations": [ "installations": [
{ {
"account": { "account": {
@ -2772,7 +2779,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"id": 0 "id": 0
} }
], ],
"type": "string",
"user": { "user": {
"avatar_url": "string", "avatar_url": "string",
"login": "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. | | `app_installable` | boolean | false | | App installable is true if the request for app installs was successful. |
| `authenticated` | boolean | false | | | | `authenticated` | boolean | false | | |
| `device` | 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. | | `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. | | `user` | [codersdk.ExternalAuthUser](#codersdkexternalauthuser) | false | | User is the user that authenticated with the provider. |
## codersdk.ExternalAuthAppInstallation ## codersdk.ExternalAuthAppInstallation
@ -2817,6 +2823,49 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `configure_url` | string | false | | | | `configure_url` | string | false | | |
| `id` | integer | 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 ## codersdk.ExternalAuthDevice
```json ```json
@ -2839,24 +2888,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `user_code` | string | false | | | | `user_code` | string | false | | |
| `verification_uri` | string | false | | | | `verification_uri` | string | false | | |
## codersdk.ExternalAuthProvider
```json
"azure-devops"
```
### Properties
#### Enumerated Values
| Value |
| ---------------- |
| `azure-devops` |
| `github` |
| `gitlab` |
| `bitbucket` |
| `openid-connect` |
## codersdk.ExternalAuthUser ## codersdk.ExternalAuthUser
```json ```json
@ -2945,44 +2976,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `count` | integer | false | | | | `count` | integer | false | | |
| `users` | array of [codersdk.User](#codersdkuser) | 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 ## codersdk.GitSSHKey
```json ```json
@ -4741,19 +4734,23 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
{ {
"authenticate_url": "string", "authenticate_url": "string",
"authenticated": true, "authenticated": true,
"display_icon": "string",
"display_name": "string",
"id": "string", "id": "string",
"type": "azure-devops" "type": "string"
} }
``` ```
### Properties ### Properties
| Name | Type | Required | Restrictions | Description | | Name | Type | Required | Restrictions | Description |
| ------------------ | -------------------------------------------------------------- | -------- | ------------ | ----------- | | ------------------ | ------- | -------- | ------------ | ----------- |
| `authenticate_url` | string | false | | | | `authenticate_url` | string | false | | |
| `authenticated` | boolean | false | | | | `authenticated` | boolean | false | | |
| `id` | string | false | | | | `display_icon` | string | false | | |
| `type` | [codersdk.ExternalAuthProvider](#codersdkexternalauthprovider) | false | | | | `display_name` | string | false | | |
| `id` | string | false | | |
| `type` | string | false | | |
## codersdk.TemplateVersionParameter ## 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 ```shell
# Example request using curl # 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 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY' -H 'Coder-Session-Token: API_KEY'
``` ```
`GET /templateversions/{templateversion}/externalauth` `GET /templateversions/{templateversion}/external-auth`
### Parameters ### Parameters
@ -1828,8 +1828,10 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/e
{ {
"authenticate_url": "string", "authenticate_url": "string",
"authenticated": true, "authenticated": true,
"display_icon": "string",
"display_name": "string",
"id": "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** Status Code **200**
| Name | Type | Required | Restrictions | Description | | Name | Type | Required | Restrictions | Description |
| -------------------- | ------------------------------------------------------------------------ | -------- | ------------ | ----------- | | -------------------- | ------- | -------- | ------------ | ----------- |
| `[array item]` | array | false | | | | `[array item]` | array | false | | |
| `» authenticate_url` | string | false | | | | `» authenticate_url` | string | false | | |
| `» authenticated` | boolean | false | | | | `» authenticated` | boolean | false | | |
| `» id` | string | false | | | | `» display_icon` | string | false | | |
| `» type` | [codersdk.ExternalAuthProvider](schemas.md#codersdkexternalauthprovider) | false | | | | `» display_name` | string | false | | |
| `» id` | string | false | | |
#### Enumerated Values | `» type` | string | false | | |
| Property | Value |
| -------- | ---------------- |
| `type` | `azure-devops` |
| `type` | `github` |
| `type` | `gitlab` |
| `type` | `bitbucket` |
| `type` | `openid-connect` |
To perform this operation, you must be authenticated. [Learn more](authentication.md). 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" "icon_path": "./images/icons/toggle_on.svg"
}, },
{ {
"title": "Git Providers", "title": "External Auth",
"description": "Learn how connect Coder with external git providers", "description": "Learn how connect Coder with external auth providers",
"path": "./admin/git-providers.md", "path": "./admin/external-auth.md",
"icon_path": "./images/icons/git.svg" "icon_path": "./images/icons/git.svg"
}, },
{ {

View File

@ -57,6 +57,7 @@ export default defineConfig({
CODER_GITAUTH_0_ID: gitAuth.deviceProvider, CODER_GITAUTH_0_ID: gitAuth.deviceProvider,
CODER_GITAUTH_0_TYPE: "github", CODER_GITAUTH_0_TYPE: "github",
CODER_GITAUTH_0_CLIENT_ID: "client", CODER_GITAUTH_0_CLIENT_ID: "client",
CODER_GITAUTH_0_CLIENT_SECRET: "secret",
CODER_GITAUTH_0_DEVICE_FLOW: "true", CODER_GITAUTH_0_DEVICE_FLOW: "true",
CODER_GITAUTH_0_APP_INSTALL_URL: CODER_GITAUTH_0_APP_INSTALL_URL:
"https://github.com/apps/coder/installations/new", "https://github.com/apps/coder/installations/new",

View File

@ -46,7 +46,7 @@ test("external auth device", async ({ page }) => {
sentPending.done(); sentPending.done();
}); });
await page.goto(`/externalauth/${gitAuth.deviceProvider}`, { await page.goto(`/external-auth/${gitAuth.deviceProvider}`, {
waitUntil: "domcontentloaded", waitUntil: "domcontentloaded",
}); });
await page.getByText(device.user_code).isVisible(); await page.getByText(device.user_code).isVisible();
@ -70,11 +70,11 @@ test("external auth web", async ({ baseURL, page }) => {
}); });
srv.use(gitAuth.authPath, (req, res) => { srv.use(gitAuth.authPath, (req, res) => {
res.redirect( res.redirect(
`${baseURL}/externalauth/${gitAuth.webProvider}/callback?code=1234&state=` + `${baseURL}/external-auth/${gitAuth.webProvider}/callback?code=1234&state=` +
req.query.state, req.query.state,
); );
}); });
await page.goto(`/externalauth/${gitAuth.webProvider}`, { await page.goto(`/external-auth/${gitAuth.webProvider}`, {
waitUntil: "domcontentloaded", waitUntil: "domcontentloaded",
}); });
// This endpoint doesn't have the installations URL set intentionally! // This endpoint doesn't have the installations URL set intentionally!

View File

@ -110,10 +110,10 @@ const UserAuthSettingsPage = lazy(
"./pages/DeploySettingsPage/UserAuthSettingsPage/UserAuthSettingsPage" "./pages/DeploySettingsPage/UserAuthSettingsPage/UserAuthSettingsPage"
), ),
); );
const GitAuthSettingsPage = lazy( const ExternalAuthSettingsPage = lazy(
() => () =>
import( import(
"./pages/DeploySettingsPage/GitAuthSettingsPage/GitAuthSettingsPage" "./pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage"
), ),
); );
const NetworkSettingsPage = lazy( const NetworkSettingsPage = lazy(
@ -210,7 +210,7 @@ export const AppRouter: FC = () => {
<Route path="health" element={<HealthPage />} /> <Route path="health" element={<HealthPage />} />
<Route <Route
path="externalauth/:provider" path="external-auth/:provider"
element={<ExternalAuthPage />} element={<ExternalAuthPage />}
/> />
@ -292,7 +292,10 @@ export const AppRouter: FC = () => {
<Route path="appearance" element={<AppearanceSettingsPage />} /> <Route path="appearance" element={<AppearanceSettingsPage />} />
<Route path="network" element={<NetworkSettingsPage />} /> <Route path="network" element={<NetworkSettingsPage />} />
<Route path="userauth" element={<UserAuthSettingsPage />} /> <Route path="userauth" element={<UserAuthSettingsPage />} />
<Route path="gitauth" element={<GitAuthSettingsPage />} /> <Route
path="external-auth"
element={<ExternalAuthSettingsPage />}
/>
<Route <Route
path="workspace-proxies" path="workspace-proxies"
element={<WorkspaceProxyPage />} element={<WorkspaceProxyPage />}

View File

@ -338,7 +338,7 @@ export const getTemplateVersionExternalAuth = async (
versionId: string, versionId: string,
): Promise<TypesGen.TemplateVersionExternalAuth[]> => { ): Promise<TypesGen.TemplateVersionExternalAuth[]> => {
const response = await axios.get( const response = await axios.get(
`/api/v2/templateversions/${versionId}/externalauth`, `/api/v2/templateversions/${versionId}/external-auth`,
); );
return response.data; return response.data;
}; };
@ -861,14 +861,14 @@ export const getExperiments = async (): Promise<TypesGen.Experiment[]> => {
export const getExternalAuthProvider = async ( export const getExternalAuthProvider = async (
provider: string, provider: string,
): Promise<TypesGen.ExternalAuth> => { ): 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; return resp.data;
}; };
export const getExternalAuthDevice = async ( export const getExternalAuthDevice = async (
provider: string, provider: string,
): Promise<TypesGen.ExternalAuthDevice> => { ): 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; return resp.data;
}; };
@ -876,7 +876,10 @@ export const exchangeExternalAuthDevice = async (
provider: string, provider: string,
req: TypesGen.ExternalAuthDeviceExchange, req: TypesGen.ExternalAuthDeviceExchange,
): Promise<void> => { ): 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; return resp.data;
}; };

View File

@ -396,7 +396,7 @@ export interface DeploymentValues {
readonly disable_session_expiry_refresh?: boolean; readonly disable_session_expiry_refresh?: boolean;
readonly disable_password_auth?: boolean; readonly disable_password_auth?: boolean;
readonly support?: SupportConfig; readonly support?: SupportConfig;
readonly git_auth?: GitAuthConfig[]; readonly external_auth?: ExternalAuthConfig[];
readonly config_ssh?: SSHConfig; readonly config_ssh?: SSHConfig;
readonly wgtunnel_host?: string; readonly wgtunnel_host?: string;
readonly disable_owner_workspace_exec?: boolean; readonly disable_owner_workspace_exec?: boolean;
@ -426,7 +426,7 @@ export type Experiments = Experiment[];
export interface ExternalAuth { export interface ExternalAuth {
readonly authenticated: boolean; readonly authenticated: boolean;
readonly device: boolean; readonly device: boolean;
readonly type: string; readonly display_name: string;
readonly user?: ExternalAuthUser; readonly user?: ExternalAuthUser;
readonly app_installable: boolean; readonly app_installable: boolean;
readonly installations: ExternalAuthAppInstallation[]; readonly installations: ExternalAuthAppInstallation[];
@ -440,6 +440,25 @@ export interface ExternalAuthAppInstallation {
readonly configure_url: string; 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 // From codersdk/externalauth.go
export interface ExternalAuthDevice { export interface ExternalAuthDevice {
readonly device_code: string; readonly device_code: string;
@ -481,23 +500,6 @@ export interface GetUsersResponse {
readonly count: number; 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 // From codersdk/gitsshkey.go
export interface GitSSHKey { export interface GitSSHKey {
readonly user_id: string; readonly user_id: string;
@ -1013,7 +1015,9 @@ export interface TemplateVersion {
// From codersdk/templateversions.go // From codersdk/templateversions.go
export interface TemplateVersionExternalAuth { export interface TemplateVersionExternalAuth {
readonly id: string; readonly id: string;
readonly type: ExternalAuthProvider; readonly type: string;
readonly display_name: string;
readonly display_icon: string;
readonly authenticate_url: string; readonly authenticate_url: string;
readonly authenticated: boolean; readonly authenticated: boolean;
} }
@ -1631,6 +1635,19 @@ export const DisplayApps: DisplayApp[] = [
"web_terminal", "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 // From codersdk/deployment.go
export type Entitlement = "entitled" | "grace_period" | "not_entitled"; export type Entitlement = "entitled" | "grace_period" | "not_entitled";
export const Entitlements: Entitlement[] = [ export const Entitlements: Entitlement[] = [
@ -1656,21 +1673,6 @@ export const Experiments: Experiment[] = [
"workspace_actions", "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 // From codersdk/deployment.go
export type FeatureName = export type FeatureName =
| "advanced_template_scheduling" | "advanced_template_scheduling"

View File

@ -94,12 +94,16 @@ export const ExternalAuth: Story = {
type: "github", type: "github",
authenticated: false, authenticated: false,
authenticate_url: "", authenticate_url: "",
display_icon: "/icon/github.svg",
display_name: "GitHub",
}, },
{ {
id: "gitlab", id: "gitlab",
type: "gitlab", type: "gitlab",
authenticated: true, authenticated: true,
authenticate_url: "", 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 && ( {externalAuth && externalAuth.length > 0 && (
<FormSection <FormSection
title="Git Authentication" title="External Authentication"
description="This template requires authentication to automatically perform Git operations on create." description="This template requires authentication to external services."
> >
<FormFields> <FormFields>
{externalAuth.map((auth) => ( {externalAuth.map((auth) => (
@ -174,7 +174,8 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
authenticated={auth.authenticated} authenticated={auth.authenticated}
externalAuthPollingState={externalAuthPollingState} externalAuthPollingState={externalAuthPollingState}
startPollingExternalAuth={startPollingExternalAuth} startPollingExternalAuth={startPollingExternalAuth}
type={auth.type} displayName={auth.display_name}
displayIcon={auth.display_icon}
error={externalAuthErrors[auth.id]} error={externalAuthErrors[auth.id]}
/> />
))} ))}

View File

@ -11,56 +11,64 @@ type Story = StoryObj<typeof ExternalAuth>;
export const GithubNotAuthenticated: Story = { export const GithubNotAuthenticated: Story = {
args: { args: {
type: "github", displayIcon: "/icon/github.svg",
displayName: "GitHub",
authenticated: false, authenticated: false,
}, },
}; };
export const GithubAuthenticated: Story = { export const GithubAuthenticated: Story = {
args: { args: {
type: "github", displayIcon: "/icon/github.svg",
displayName: "GitHub",
authenticated: true, authenticated: true,
}, },
}; };
export const GitlabNotAuthenticated: Story = { export const GitlabNotAuthenticated: Story = {
args: { args: {
type: "gitlab", displayIcon: "/icon/gitlab.svg",
displayName: "GitLab",
authenticated: false, authenticated: false,
}, },
}; };
export const GitlabAuthenticated: Story = { export const GitlabAuthenticated: Story = {
args: { args: {
type: "gitlab", displayIcon: "/icon/gitlab.svg",
displayName: "GitLab",
authenticated: true, authenticated: true,
}, },
}; };
export const AzureDevOpsNotAuthenticated: Story = { export const AzureDevOpsNotAuthenticated: Story = {
args: { args: {
type: "azure-devops", displayIcon: "/icon/azure-devops.svg",
displayName: "Azure DevOps",
authenticated: false, authenticated: false,
}, },
}; };
export const AzureDevOpsAuthenticated: Story = { export const AzureDevOpsAuthenticated: Story = {
args: { args: {
type: "azure-devops", displayIcon: "/icon/azure-devops.svg",
displayName: "Azure DevOps",
authenticated: true, authenticated: true,
}, },
}; };
export const BitbucketNotAuthenticated: Story = { export const BitbucketNotAuthenticated: Story = {
args: { args: {
type: "bitbucket", displayIcon: "/icon/bitbucket.svg",
displayName: "Bitbucket",
authenticated: false, authenticated: false,
}, },
}; };
export const BitbucketAuthenticated: Story = { export const BitbucketAuthenticated: Story = {
args: { args: {
type: "bitbucket", displayIcon: "/icon/bitbucket.svg",
displayName: "Bitbucket",
authenticated: true, authenticated: true,
}, },
}; };

View File

@ -1,21 +1,16 @@
import ReplayIcon from "@mui/icons-material/Replay";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import FormHelperText from "@mui/material/FormHelperText"; import FormHelperText from "@mui/material/FormHelperText";
import { SvgIconProps } from "@mui/material/SvgIcon";
import Tooltip from "@mui/material/Tooltip"; 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 { 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 { LoadingButton } from "components/LoadingButton/LoadingButton";
import { Stack } from "components/Stack/Stack";
import { FC } from "react";
import { type ExternalAuthPollingState } from "./CreateWorkspacePage";
export interface ExternalAuthProps { export interface ExternalAuthProps {
type: TypesGen.ExternalAuthProvider; displayName: string;
displayIcon: string;
authenticated: boolean; authenticated: boolean;
authenticateURL: string; authenticateURL: string;
externalAuthPollingState: ExternalAuthPollingState; externalAuthPollingState: ExternalAuthPollingState;
@ -25,7 +20,8 @@ export interface ExternalAuthProps {
export const ExternalAuth: FC<ExternalAuthProps> = (props) => { export const ExternalAuth: FC<ExternalAuthProps> = (props) => {
const { const {
type, displayName,
displayIcon,
authenticated, authenticated,
authenticateURL, authenticateURL,
externalAuthPollingState, externalAuthPollingState,
@ -37,32 +33,9 @@ export const ExternalAuth: FC<ExternalAuthProps> = (props) => {
error: typeof error !== "undefined", 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 ( return (
<Tooltip <Tooltip
title={authenticated && `${prettyName} has already been connected.`} title={authenticated && `${displayName} has already been connected.`}
> >
<Stack alignItems="center" spacing={1}> <Stack alignItems="center" spacing={1}>
<LoadingButton <LoadingButton
@ -70,7 +43,14 @@ export const ExternalAuth: FC<ExternalAuthProps> = (props) => {
href={authenticateURL} href={authenticateURL}
variant="contained" variant="contained"
size="large" size="large"
startIcon={<Icon />} startIcon={
<img
src={displayIcon}
alt={`${displayName} Icon`}
width={24}
height={24}
/>
}
disabled={authenticated} disabled={authenticated}
className={styles.button} className={styles.button}
color={error ? "error" : undefined} color={error ? "error" : undefined}
@ -86,8 +66,8 @@ export const ExternalAuth: FC<ExternalAuthProps> = (props) => {
}} }}
> >
{authenticated {authenticated
? `Authenticated with ${prettyName}` ? `Authenticated with ${displayName}`
: `Login with ${prettyName}`} : `Login with ${displayName}`}
</LoadingButton> </LoadingButton>
{externalAuthPollingState === "abandoned" && ( {externalAuthPollingState === "abandoned" && (

View File

@ -2,20 +2,20 @@ import { useDeploySettings } from "components/DeploySettingsLayout/DeploySetting
import { FC } from "react"; import { FC } from "react";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { pageTitle } from "utils/page"; import { pageTitle } from "utils/page";
import { GitAuthSettingsPageView } from "./GitAuthSettingsPageView"; import { ExternalAuthSettingsPageView } from "./ExternalAuthSettingsPageView";
const GitAuthSettingsPage: FC = () => { const ExternalAuthSettingsPage: FC = () => {
const { deploymentValues: deploymentValues } = useDeploySettings(); const { deploymentValues: deploymentValues } = useDeploySettings();
return ( return (
<> <>
<Helmet> <Helmet>
<title>{pageTitle("Git Authentication Settings")}</title> <title>{pageTitle("External Authentication Settings")}</title>
</Helmet> </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"; import type { Meta, StoryObj } from "@storybook/react";
const meta: Meta<typeof GitAuthSettingsPageView> = { const meta: Meta<typeof ExternalAuthSettingsPageView> = {
title: "pages/GitAuthSettingsPageView", title: "pages/ExternalAuthSettingsPageView",
component: GitAuthSettingsPageView, component: ExternalAuthSettingsPageView,
args: { args: {
config: { config: {
git_auth: [ external_auth: [
{ {
id: "0000-1111", id: "0000-1111",
type: "GitHub", type: "GitHub",
@ -21,6 +21,8 @@ const meta: Meta<typeof GitAuthSettingsPageView> = {
scopes: [], scopes: [],
device_flow: true, device_flow: true,
device_code_url: "", device_code_url: "",
display_icon: "",
display_name: "GitHub",
}, },
], ],
}, },
@ -28,6 +30,6 @@ const meta: Meta<typeof GitAuthSettingsPageView> = {
}; };
export default meta; export default meta;
type Story = StoryObj<typeof GitAuthSettingsPageView>; type Story = StoryObj<typeof ExternalAuthSettingsPageView>;
export const Page: Story = {}; export const Page: Story = {};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,4 @@
import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon"; <svg viewBox="0 0 111 110" xmlns="http://www.w3.org/2000/svg">
export const AzureDevOpsIcon = (props: SvgIconProps): JSX.Element => (
<SvgIcon {...props} viewBox="0 0 111 110">
<g clipPath="url(#clip0_1916_993)"> <g clipPath="url(#clip0_1916_993)">
<path <path
d="M83.0365 94.769L0 82.288L83.0365 110L111 98.365V11.2115L83.0365 0V94.769Z" 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" /> <rect width="111" height="110" fill="white" />
</clipPath> </clipPath>
</defs> </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"; <svg viewBox="0 0 501 450" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1917_1001)">
export const BitbucketIcon = (props: SvgIconProps): JSX.Element => (
<SvgIcon {...props} viewBox="0 0 501 450">
<g clipPath="url(#clip0_1917_1001)">
<path <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" 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" fill="#2684FF"
@ -21,12 +18,11 @@ export const BitbucketIcon = (props: SvgIconProps): JSX.Element => (
y2="386.327" y2="386.327"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop offset="0.18" stopColor="#0052CC" /> <stop offset="0.18" stop-color="#0052CC" />
<stop offset="1" stopColor="#2684FF" /> <stop offset="1" stop-color="#2684FF" />
</linearGradient> </linearGradient>
<clipPath id="clip0_1917_1001"> <clipPath id="clip0_1917_1001">
<rect width="501" height="450" fill="white" /> <rect width="501" height="450" fill="white" />
</clipPath> </clipPath>
</defs> </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