feat: add `external-auth` cli (#10052)

* feat: add `external-auth` cli

* Add subcommands

* Improve descriptions

* Add external-auth subcommand

* Fix docs

* Fix gen

* Fix comment

* Fix golden file
This commit is contained in:
Kyle Carberry 2023-10-09 18:04:35 -05:00 committed by GitHub
parent 20438ae6c2
commit 35538e1051
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 613 additions and 105 deletions

91
cli/externalauth.go Normal file
View File

@ -0,0 +1,91 @@
package cli
import (
"os/signal"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk/agentsdk"
)
func (r *RootCmd) externalAuth() *clibase.Cmd {
return &clibase.Cmd{
Use: "external-auth",
Short: "Manage external authentication",
Long: "Authenticate with external services inside of a workspace.",
Handler: func(i *clibase.Invocation) error {
return i.Command.HelpHandler(i)
},
Children: []*clibase.Cmd{
r.externalAuthAccessToken(),
},
}
}
func (r *RootCmd) externalAuthAccessToken() *clibase.Cmd {
var silent bool
return &clibase.Cmd{
Use: "access-token <provider>",
Short: "Print auth for an external provider",
Long: "Print an access-token for an external auth provider. " +
"The access-token will be validated and sent to stdout with exit code 0. " +
"If a valid access-token cannot be obtained, the URL to authenticate will be sent to stdout with exit code 1\n" + formatExamples(
example{
Description: "Ensure that the user is authenticated with GitHub before cloning.",
Command: `#!/usr/bin/env sh
OUTPUT=$(coder external-auth access-token github)
if [ $? -eq 0 ]; then
echo "Authenticated with GitHub"
else
echo "Please authenticate with GitHub:"
echo $OUTPUT
fi
`,
},
),
Options: clibase.OptionSet{{
Name: "Silent",
Flag: "s",
Description: "Do not print the URL or access token.",
Value: clibase.BoolOf(&silent),
}},
Handler: func(inv *clibase.Invocation) error {
ctx := inv.Context()
ctx, stop := signal.NotifyContext(ctx, InterruptSignals...)
defer stop()
client, err := r.createAgentClient()
if err != nil {
return xerrors.Errorf("create agent client: %w", err)
}
token, err := client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
ID: inv.Args[0],
})
if err != nil {
return xerrors.Errorf("get external auth token: %w", err)
}
if !silent {
if token.URL != "" {
_, err = inv.Stdout.Write([]byte(token.URL))
} else {
_, err = inv.Stdout.Write([]byte(token.AccessToken))
}
if err != nil {
return err
}
}
if token.URL != "" {
return cliui.Canceled
}
return nil
},
}
}

49
cli/externalauth_test.go Normal file
View File

@ -0,0 +1,49 @@
package cli_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/pty/ptytest"
)
func TestExternalAuth(t *testing.T) {
t.Parallel()
t.Run("CanceledWithURL", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
URL: "https://github.com",
})
}))
t.Cleanup(srv.Close)
url := srv.URL
inv, _ := clitest.New(t, "--agent-url", url, "external-auth", "access-token", "github")
pty := ptytest.New(t)
inv.Stdout = pty.Output()
waiter := clitest.StartWithWaiter(t, inv)
pty.ExpectMatch("https://github.com")
waiter.RequireIs(cliui.Canceled)
})
t.Run("SuccessWithToken", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
AccessToken: "bananas",
})
}))
t.Cleanup(srv.Close)
url := srv.URL
inv, _ := clitest.New(t, "--agent-url", url, "external-auth", "access-token", "github")
pty := ptytest.New(t)
inv.Stdout = pty.Output()
clitest.Start(t, inv)
pty.ExpectMatch("bananas")
})
}

View File

@ -13,6 +13,7 @@ import (
"github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/gitauth" "github.com/coder/coder/v2/cli/gitauth"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/retry" "github.com/coder/retry"
) )
@ -38,7 +39,9 @@ func (r *RootCmd) gitAskpass() *clibase.Cmd {
return xerrors.Errorf("create agent client: %w", err) return xerrors.Errorf("create agent client: %w", err)
} }
token, err := client.GitAuth(ctx, host, false) token, err := client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
Match: host,
})
if err != nil { if err != nil {
var apiError *codersdk.Error var apiError *codersdk.Error
if errors.As(err, &apiError) && apiError.StatusCode() == http.StatusNotFound { if errors.As(err, &apiError) && apiError.StatusCode() == http.StatusNotFound {
@ -63,7 +66,10 @@ func (r *RootCmd) gitAskpass() *clibase.Cmd {
} }
for r := retry.New(250*time.Millisecond, 10*time.Second); r.Wait(ctx); { for r := retry.New(250*time.Millisecond, 10*time.Second); r.Wait(ctx); {
token, err = client.GitAuth(ctx, host, true) token, err = client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
Match: host,
Listen: true,
})
if err != nil { if err != nil {
continue continue
} }

View File

@ -23,7 +23,7 @@ func TestGitAskpass(t *testing.T) {
t.Run("UsernameAndPassword", func(t *testing.T) { t.Run("UsernameAndPassword", func(t *testing.T) {
t.Parallel() t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.GitAuthResponse{ httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
Username: "something", Username: "something",
Password: "bananas", Password: "bananas",
}) })
@ -65,8 +65,8 @@ func TestGitAskpass(t *testing.T) {
t.Run("Poll", func(t *testing.T) { t.Run("Poll", func(t *testing.T) {
t.Parallel() t.Parallel()
resp := atomic.Pointer[agentsdk.GitAuthResponse]{} resp := atomic.Pointer[agentsdk.ExternalAuthResponse]{}
resp.Store(&agentsdk.GitAuthResponse{ resp.Store(&agentsdk.ExternalAuthResponse{
URL: "https://something.org", URL: "https://something.org",
}) })
poll := make(chan struct{}, 10) poll := make(chan struct{}, 10)
@ -96,7 +96,7 @@ func TestGitAskpass(t *testing.T) {
}() }()
<-poll <-poll
stderr.ExpectMatch("Open the following URL to authenticate") stderr.ExpectMatch("Open the following URL to authenticate")
resp.Store(&agentsdk.GitAuthResponse{ resp.Store(&agentsdk.ExternalAuthResponse{
Username: "username", Username: "username",
Password: "password", Password: "password",
}) })

View File

@ -83,6 +83,7 @@ func (r *RootCmd) Core() []*clibase.Cmd {
// Please re-sort this list alphabetically if you change it! // Please re-sort this list alphabetically if you change it!
return []*clibase.Cmd{ return []*clibase.Cmd{
r.dotfiles(), r.dotfiles(),
r.externalAuth(),
r.login(), r.login(),
r.logout(), r.logout(),
r.netcheck(), r.netcheck(),

View File

@ -20,6 +20,7 @@ SUBCOMMANDS:
delete Delete a workspace delete Delete a workspace
dotfiles Personalize your workspace by applying a canonical dotfiles Personalize your workspace by applying a canonical
dotfiles repository dotfiles repository
external-auth Manage external authentication
list List workspaces list List workspaces
login Authenticate with Coder deployment login Authenticate with Coder deployment
logout Unauthenticate your local session logout Unauthenticate your local session

View File

@ -0,0 +1,14 @@
coder v0.0.0-devel
USAGE:
coder external-auth
Manage external authentication
Authenticate with external services inside of a workspace.
SUBCOMMANDS:
access-token Print auth for an external provider
———
Run `coder --help` for a list of global options.

View File

@ -0,0 +1,28 @@
coder v0.0.0-devel
USAGE:
coder external-auth access-token [flags] <provider>
Print auth for an external provider
Print an access-token for an external auth provider. The access-token will be
validated and sent to stdout with exit code 0. If a valid access-token cannot
be obtained, the URL to authenticate will be sent to stdout with exit code 1
- Ensure that the user is authenticated with GitHub before cloning.:
$ #!/usr/bin/env sh
OUTPUT=$(coder external-auth access-token github)
if [ $? -eq 0 ]; then
echo "Authenticated with GitHub"
else
echo "Please authenticate with GitHub:"
echo $OUTPUT
fi
OPTIONS:
--s bool
Do not print the URL or access token.
———
Run `coder --help` for a list of global options.

76
coderd/apidoc/docs.go generated
View File

@ -4561,7 +4561,7 @@ const docTemplate = `{
} }
} }
}, },
"/workspaceagents/me/gitauth": { "/workspaceagents/me/external-auth": {
"get": { "get": {
"security": [ "security": [
{ {
@ -4574,14 +4574,20 @@ const docTemplate = `{
"tags": [ "tags": [
"Agents" "Agents"
], ],
"summary": "Get workspace agent Git auth", "summary": "Get workspace agent external auth",
"operationId": "get-workspace-agent-git-auth", "operationId": "get-workspace-agent-external-auth",
"parameters": [ "parameters": [
{ {
"type": "string", "type": "string",
"format": "uri", "description": "Match",
"description": "Git URL", "name": "match",
"name": "url", "in": "query",
"required": true
},
{
"type": "string",
"description": "Provider ID",
"name": "id",
"in": "query", "in": "query",
"required": true "required": true
}, },
@ -4596,7 +4602,54 @@ const docTemplate = `{
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"$ref": "#/definitions/agentsdk.GitAuthResponse" "$ref": "#/definitions/agentsdk.ExternalAuthResponse"
}
}
}
}
},
"/workspaceagents/me/gitauth": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Agents"
],
"summary": "Removed: Get workspace agent git auth",
"operationId": "removed-get-workspace-agent-git-auth",
"parameters": [
{
"type": "string",
"description": "Match",
"name": "match",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Provider ID",
"name": "id",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Wait for a new token to be issued",
"name": "listen",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/agentsdk.ExternalAuthResponse"
} }
} }
} }
@ -6417,16 +6470,23 @@ const docTemplate = `{
} }
} }
}, },
"agentsdk.GitAuthResponse": { "agentsdk.ExternalAuthResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"access_token": {
"type": "string"
},
"password": { "password": {
"type": "string" "type": "string"
}, },
"type": {
"type": "string"
},
"url": { "url": {
"type": "string" "type": "string"
}, },
"username": { "username": {
"description": "Deprecated: Only supported on ` + "`" + `/workspaceagents/me/gitauth` + "`" + `\nfor backwards compatibility.",
"type": "string" "type": "string"
} }
} }

View File

@ -4011,7 +4011,7 @@
} }
} }
}, },
"/workspaceagents/me/gitauth": { "/workspaceagents/me/external-auth": {
"get": { "get": {
"security": [ "security": [
{ {
@ -4020,14 +4020,20 @@
], ],
"produces": ["application/json"], "produces": ["application/json"],
"tags": ["Agents"], "tags": ["Agents"],
"summary": "Get workspace agent Git auth", "summary": "Get workspace agent external auth",
"operationId": "get-workspace-agent-git-auth", "operationId": "get-workspace-agent-external-auth",
"parameters": [ "parameters": [
{ {
"type": "string", "type": "string",
"format": "uri", "description": "Match",
"description": "Git URL", "name": "match",
"name": "url", "in": "query",
"required": true
},
{
"type": "string",
"description": "Provider ID",
"name": "id",
"in": "query", "in": "query",
"required": true "required": true
}, },
@ -4042,7 +4048,50 @@
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"$ref": "#/definitions/agentsdk.GitAuthResponse" "$ref": "#/definitions/agentsdk.ExternalAuthResponse"
}
}
}
}
},
"/workspaceagents/me/gitauth": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Agents"],
"summary": "Removed: Get workspace agent git auth",
"operationId": "removed-get-workspace-agent-git-auth",
"parameters": [
{
"type": "string",
"description": "Match",
"name": "match",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Provider ID",
"name": "id",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Wait for a new token to be issued",
"name": "listen",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/agentsdk.ExternalAuthResponse"
} }
} }
} }
@ -5651,16 +5700,23 @@
} }
} }
}, },
"agentsdk.GitAuthResponse": { "agentsdk.ExternalAuthResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"access_token": {
"type": "string"
},
"password": { "password": {
"type": "string" "type": "string"
}, },
"type": {
"type": "string"
},
"url": { "url": {
"type": "string" "type": "string"
}, },
"username": { "username": {
"description": "Deprecated: Only supported on `/workspaceagents/me/gitauth`\nfor backwards compatibility.",
"type": "string" "type": "string"
} }
} }

View File

@ -812,7 +812,9 @@ func New(options *Options) *API {
r.Patch("/startup-logs", api.patchWorkspaceAgentLogsDeprecated) r.Patch("/startup-logs", api.patchWorkspaceAgentLogsDeprecated)
r.Patch("/logs", api.patchWorkspaceAgentLogs) r.Patch("/logs", api.patchWorkspaceAgentLogs)
r.Post("/app-health", api.postWorkspaceAppHealth) r.Post("/app-health", api.postWorkspaceAppHealth)
// Deprecated: Required to support legacy agents
r.Get("/gitauth", api.workspaceAgentsGitAuth) r.Get("/gitauth", api.workspaceAgentsGitAuth)
r.Get("/external-auth", api.workspaceAgentsExternalAuth)
r.Get("/gitsshkey", api.agentGitSSHKey) r.Get("/gitsshkey", api.agentGitSSHKey)
r.Get("/coordinate", api.workspaceAgentCoordinate) r.Get("/coordinate", api.workspaceAgentCoordinate)
r.Post("/report-stats", api.workspaceAgentReportStats) r.Post("/report-stats", api.workspaceAgentReportStats)

View File

@ -56,3 +56,17 @@ func (api *API) patchWorkspaceAgentLogsDeprecated(rw http.ResponseWriter, r *htt
func (api *API) workspaceAgentLogsDeprecated(rw http.ResponseWriter, r *http.Request) { func (api *API) workspaceAgentLogsDeprecated(rw http.ResponseWriter, r *http.Request) {
api.workspaceAgentLogs(rw, r) api.workspaceAgentLogs(rw, r)
} }
// @Summary Removed: Get workspace agent git auth
// @ID removed-get-workspace-agent-git-auth
// @Security CoderSessionToken
// @Produce json
// @Tags Agents
// @Param match query string true "Match"
// @Param id query string true "Provider ID"
// @Param listen query bool false "Wait for a new token to be issued"
// @Success 200 {object} agentsdk.ExternalAuthResponse
// @Router /workspaceagents/me/gitauth [get]
func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) {
api.workspaceAgentsExternalAuth(rw, r)
}

View File

@ -132,7 +132,7 @@ func TestExternalAuthByID(t *testing.T) {
}) })
} }
func TestGitAuthDevice(t *testing.T) { func TestExternalAuthDevice(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("NotSupported", func(t *testing.T) { t.Run("NotSupported", func(t *testing.T) {
t.Parallel() t.Parallel()
@ -214,7 +214,7 @@ func TestGitAuthDevice(t *testing.T) {
} }
// nolint:bodyclose // nolint:bodyclose
func TestGitAuthCallback(t *testing.T) { func TestExternalAuthCallback(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("NoMatchingConfig", func(t *testing.T) { t.Run("NoMatchingConfig", func(t *testing.T) {
t.Parallel() t.Parallel()
@ -236,7 +236,9 @@ func TestGitAuthCallback(t *testing.T) {
agentClient := agentsdk.New(client.URL) agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken) agentClient.SetSessionToken(authToken)
_, err := agentClient.GitAuth(context.Background(), "github.com", false) _, err := agentClient.ExternalAuth(context.Background(), agentsdk.ExternalAuthRequest{
Match: "github.com",
})
var apiError *codersdk.Error var apiError *codersdk.Error
require.ErrorAs(t, err, &apiError) require.ErrorAs(t, err, &apiError)
require.Equal(t, http.StatusNotFound, apiError.StatusCode()) require.Equal(t, http.StatusNotFound, apiError.StatusCode())
@ -266,7 +268,9 @@ func TestGitAuthCallback(t *testing.T) {
agentClient := agentsdk.New(client.URL) agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken) agentClient.SetSessionToken(authToken)
token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) token, err := agentClient.ExternalAuth(context.Background(), agentsdk.ExternalAuthRequest{
Match: "github.com/asd/asd",
})
require.NoError(t, err) require.NoError(t, err)
require.True(t, strings.HasSuffix(token.URL, fmt.Sprintf("/external-auth/%s", "github")), token.URL) require.True(t, strings.HasSuffix(token.URL, fmt.Sprintf("/external-auth/%s", "github")), token.URL)
}) })
@ -345,7 +349,9 @@ func TestGitAuthCallback(t *testing.T) {
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
}) })
res, err := agentClient.GitAuth(ctx, "github.com/asd/asd", false) res, err := agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
Match: "github.com/asd/asd",
})
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, res.URL) require.NotEmpty(t, res.URL)
@ -355,7 +361,9 @@ func TestGitAuthCallback(t *testing.T) {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Something went wrong!")) w.Write([]byte("Something went wrong!"))
}) })
_, err = agentClient.GitAuth(ctx, "github.com/asd/asd", false) _, err = agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
Match: "github.com/asd/asd",
})
var apiError *codersdk.Error var apiError *codersdk.Error
require.ErrorAs(t, err, &apiError) require.ErrorAs(t, err, &apiError)
require.Equal(t, http.StatusInternalServerError, apiError.StatusCode()) require.Equal(t, http.StatusInternalServerError, apiError.StatusCode())
@ -395,7 +403,9 @@ func TestGitAuthCallback(t *testing.T) {
agentClient := agentsdk.New(client.URL) agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken) agentClient.SetSessionToken(authToken)
token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) token, err := agentClient.ExternalAuth(context.Background(), agentsdk.ExternalAuthRequest{
Match: "github.com/asd/asd",
})
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, token.URL) require.NotEmpty(t, token.URL)
@ -407,7 +417,9 @@ func TestGitAuthCallback(t *testing.T) {
// Because the token is expired and `NoRefresh` is specified, // Because the token is expired and `NoRefresh` is specified,
// a redirect URL should be returned again. // a redirect URL should be returned again.
token, err = agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) token, err = agentClient.ExternalAuth(context.Background(), agentsdk.ExternalAuthRequest{
Match: "github.com/asd/asd",
})
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, token.URL) require.NotEmpty(t, token.URL)
}) })
@ -438,14 +450,19 @@ func TestGitAuthCallback(t *testing.T) {
agentClient := agentsdk.New(client.URL) agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken) agentClient.SetSessionToken(authToken)
token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) token, err := agentClient.ExternalAuth(context.Background(), agentsdk.ExternalAuthRequest{
Match: "github.com/asd/asd",
})
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, token.URL) require.NotEmpty(t, token.URL)
// Start waiting for the token callback... // Start waiting for the token callback...
tokenChan := make(chan agentsdk.GitAuthResponse, 1) tokenChan := make(chan agentsdk.ExternalAuthResponse, 1)
go func() { go func() {
token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", true) token, err := agentClient.ExternalAuth(context.Background(), agentsdk.ExternalAuthRequest{
Match: "github.com/asd/asd",
Listen: true,
})
assert.NoError(t, err) assert.NoError(t, err)
tokenChan <- token tokenChan <- token
}() }()
@ -457,7 +474,9 @@ func TestGitAuthCallback(t *testing.T) {
token = <-tokenChan token = <-tokenChan
require.Equal(t, "access_token", token.Username) require.Equal(t, "access_token", token.Username)
token, err = agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) token, err = agentClient.ExternalAuth(context.Background(), agentsdk.ExternalAuthRequest{
Match: "github.com/asd/asd",
})
require.NoError(t, err) require.NoError(t, err)
}) })
} }

View File

@ -225,13 +225,20 @@ func (api *API) workspaceAgentManifest(rw http.ResponseWriter, r *http.Request)
vscodeProxyURI += fmt.Sprintf(":%s", api.AccessURL.Port()) vscodeProxyURI += fmt.Sprintf(":%s", api.AccessURL.Port())
} }
gitAuthConfigs := 0
for _, cfg := range api.ExternalAuthConfigs {
if codersdk.EnhancedExternalAuthProvider(cfg.Type).Git() {
gitAuthConfigs++
}
}
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.Manifest{ httpapi.Write(ctx, rw, http.StatusOK, agentsdk.Manifest{
AgentID: apiAgent.ID, AgentID: apiAgent.ID,
Apps: convertApps(dbApps, workspaceAgent, owner, workspace), Apps: convertApps(dbApps, workspaceAgent, owner, workspace),
Scripts: convertScripts(scripts), Scripts: convertScripts(scripts),
DERPMap: api.DERPMap(), DERPMap: api.DERPMap(),
DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(), DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(),
GitAuthConfigs: len(api.ExternalAuthConfigs), GitAuthConfigs: gitAuthConfigs,
EnvironmentVariables: apiAgent.EnvironmentVariables, EnvironmentVariables: apiAgent.EnvironmentVariables,
Directory: apiAgent.Directory, Directory: apiAgent.Directory,
VSCodePortProxyURI: vscodeProxyURI, VSCodePortProxyURI: vscodeProxyURI,
@ -2155,44 +2162,62 @@ func (api *API) postWorkspaceAppHealth(rw http.ResponseWriter, r *http.Request)
httpapi.Write(ctx, rw, http.StatusOK, nil) httpapi.Write(ctx, rw, http.StatusOK, nil)
} }
// workspaceAgentsGitAuth returns a username and password for use // workspaceAgentsExternalAuth returns an access token for a given URL
// with GIT_ASKPASS. // or finds a provider by ID.
// //
// @Summary Get workspace agent Git auth // @Summary Get workspace agent external auth
// @ID get-workspace-agent-git-auth // @ID get-workspace-agent-external-auth
// @Security CoderSessionToken // @Security CoderSessionToken
// @Produce json // @Produce json
// @Tags Agents // @Tags Agents
// @Param url query string true "Git URL" format(uri) // @Param match query string true "Match"
// @Param id query string true "Provider ID"
// @Param listen query bool false "Wait for a new token to be issued" // @Param listen query bool false "Wait for a new token to be issued"
// @Success 200 {object} agentsdk.GitAuthResponse // @Success 200 {object} agentsdk.ExternalAuthResponse
// @Router /workspaceagents/me/gitauth [get] // @Router /workspaceagents/me/external-auth [get]
func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) { func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
gitURL := r.URL.Query().Get("url") // Either match or configID must be provided!
if gitURL == "" { match := r.URL.Query().Get("match")
if match == "" {
// Support legacy agents!
match = r.URL.Query().Get("url")
}
id := chi.URLParam(r, "id")
if match == "" && id == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Missing 'url' query parameter!", Message: "'url' or 'id' must be provided!",
}) })
return return
} }
if match != "" && id != "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "'url' and 'id' cannot be provided together!",
})
return
}
// listen determines if the request will wait for a // listen determines if the request will wait for a
// new token to be issued! // new token to be issued!
listen := r.URL.Query().Has("listen") listen := r.URL.Query().Has("listen")
var externalAuthConfig *externalauth.Config var externalAuthConfig *externalauth.Config
for _, gitAuth := range api.ExternalAuthConfigs { for _, extAuth := range api.ExternalAuthConfigs {
if gitAuth.Regex == nil { if extAuth.ID == id {
externalAuthConfig = extAuth
break
}
if match == "" || extAuth.Regex == nil {
continue continue
} }
matches := gitAuth.Regex.MatchString(gitURL) matches := extAuth.Regex.MatchString(match)
if !matches { if !matches {
continue continue
} }
externalAuthConfig = gitAuth externalAuthConfig = extAuth
} }
if externalAuthConfig == nil { if externalAuthConfig == nil {
detail := "No external auth providers are configured." detail := "External auth provider not found."
if len(api.ExternalAuthConfigs) > 0 { if len(api.ExternalAuthConfigs) > 0 {
regexURLs := make([]string, 0, len(api.ExternalAuthConfigs)) regexURLs := make([]string, 0, len(api.ExternalAuthConfigs))
for _, extAuth := range api.ExternalAuthConfigs { for _, extAuth := range api.ExternalAuthConfigs {
@ -2201,21 +2226,14 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
} }
regexURLs = append(regexURLs, fmt.Sprintf("%s=%q", extAuth.ID, extAuth.Regex.String())) regexURLs = append(regexURLs, fmt.Sprintf("%s=%q", extAuth.ID, extAuth.Regex.String()))
} }
detail = fmt.Sprintf("The configured external auth provider have regex filters that do not match the git url. Provider url regexs: %s", strings.Join(regexURLs, ",")) detail = fmt.Sprintf("The configured external auth provider have regex filters that do not match the url. Provider url regex: %s", strings.Join(regexURLs, ","))
} }
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: fmt.Sprintf("No matching external auth provider found in Coder for the url %q.", gitURL), Message: fmt.Sprintf("No matching external auth provider found in Coder for the url %q.", match),
Detail: detail, Detail: detail,
}) })
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)
@ -2287,7 +2305,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
if !valid { if !valid {
continue continue
} }
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(enhancedType, externalAuthLink.OAuthAccessToken)) httpapi.Write(ctx, rw, http.StatusOK, createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken))
return return
} }
} }
@ -2315,7 +2333,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
return return
} }
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.GitAuthResponse{ httpapi.Write(ctx, rw, http.StatusOK, agentsdk.ExternalAuthResponse{
URL: redirectURL.String(), URL: redirectURL.String(),
}) })
return return
@ -2330,35 +2348,39 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
return return
} }
if !updated { if !updated {
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.GitAuthResponse{ httpapi.Write(ctx, rw, http.StatusOK, agentsdk.ExternalAuthResponse{
URL: redirectURL.String(), URL: redirectURL.String(),
}) })
return return
} }
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(enhancedType, externalAuthLink.OAuthAccessToken)) httpapi.Write(ctx, rw, http.StatusOK, createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken))
} }
// Provider types have different username/password formats. // createExternalAuthResponse creates an ExternalAuthResponse based on the
func formatGitAuthAccessToken(typ codersdk.EnhancedExternalAuthProvider, token string) agentsdk.GitAuthResponse { // provider type. This is to support legacy `/workspaceagents/me/gitauth`
var resp agentsdk.GitAuthResponse // which uses `Username` and `Password`.
func createExternalAuthResponse(typ, token string) agentsdk.ExternalAuthResponse {
var resp agentsdk.ExternalAuthResponse
switch typ { switch typ {
case codersdk.EnhancedExternalAuthProviderGitLab: case string(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.ExternalAuthResponse{
Username: "oauth2", Username: "oauth2",
Password: token, Password: token,
} }
case codersdk.EnhancedExternalAuthProviderBitBucket: case string(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.ExternalAuthResponse{
Username: "x-token-auth", Username: "x-token-auth",
Password: token, Password: token,
} }
default: default:
resp = agentsdk.GitAuthResponse{ resp = agentsdk.ExternalAuthResponse{
Username: token, Username: token,
} }
} }
resp.AccessToken = token
resp.Type = typ
return resp return resp
} }

View File

@ -710,30 +710,51 @@ func (c *Client) GetServiceBanner(ctx context.Context) (codersdk.ServiceBannerCo
return cfg.ServiceBanner, json.NewDecoder(res.Body).Decode(&cfg) return cfg.ServiceBanner, json.NewDecoder(res.Body).Decode(&cfg)
} }
type GitAuthResponse struct { type ExternalAuthResponse struct {
AccessToken string `json:"access_token"`
URL string `json:"url"`
Type string `json:"type"`
// Deprecated: Only supported on `/workspaceagents/me/gitauth`
// for backwards compatibility.
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
URL string `json:"url"`
} }
// GitAuth submits a URL to fetch a GIT_ASKPASS username and password for. // ExternalAuthRequest is used to request an access token for a provider.
// Either ID or Match must be specified, but not both.
type ExternalAuthRequest struct {
// ID is the ID of a provider to request authentication for.
ID string
// Match is an arbitrary string matched against the regex of the provider.
Match string
// Listen indicates that the request should be long-lived and listen for
// a new token to be requested.
Listen bool
}
// ExternalAuth submits a URL or provider ID to fetch an access token for.
// nolint:revive // nolint:revive
func (c *Client) GitAuth(ctx context.Context, gitURL string, listen bool) (GitAuthResponse, error) { func (c *Client) ExternalAuth(ctx context.Context, req ExternalAuthRequest) (ExternalAuthResponse, error) {
reqURL := "/api/v2/workspaceagents/me/gitauth?url=" + url.QueryEscape(gitURL) q := url.Values{
if listen { "id": []string{req.ID},
reqURL += "&listen" "match": []string{req.Match},
} }
if req.Listen {
q.Set("listen", "true")
}
reqURL := "/api/v2/workspaceagents/me/external-auth?" + q.Encode()
res, err := c.SDK.Request(ctx, http.MethodGet, reqURL, nil) res, err := c.SDK.Request(ctx, http.MethodGet, reqURL, nil)
if err != nil { if err != nil {
return GitAuthResponse{}, xerrors.Errorf("execute request: %w", err) return ExternalAuthResponse{}, xerrors.Errorf("execute request: %w", err)
} }
defer res.Body.Close() defer res.Body.Close()
if res.StatusCode != http.StatusOK { if res.StatusCode != http.StatusOK {
return GitAuthResponse{}, codersdk.ReadBodyAsError(res) return ExternalAuthResponse{}, codersdk.ReadBodyAsError(res)
} }
var authResp GitAuthResponse var authResp ExternalAuthResponse
return authResp, json.NewDecoder(res.Body).Decode(&authResp) return authResp, json.NewDecoder(res.Body).Decode(&authResp)
} }

64
docs/api/agents.md generated
View File

@ -221,13 +221,56 @@ incoming connections and publishes node updates.
To perform this operation, you must be authenticated. [Learn more](authentication.md). To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get workspace agent Git auth ## Get workspace agent external auth
### Code samples ### Code samples
```shell ```shell
# Example request using curl # Example request using curl
curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/gitauth?url=http%3A%2F%2Fexample.com \ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/external-auth?match=string&id=string \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /workspaceagents/me/external-auth`
### Parameters
| Name | In | Type | Required | Description |
| -------- | ----- | ------- | -------- | --------------------------------- |
| `match` | query | string | true | Match |
| `id` | query | string | true | Provider ID |
| `listen` | query | boolean | false | Wait for a new token to be issued |
### Example responses
> 200 Response
```json
{
"access_token": "string",
"password": "string",
"type": "string",
"url": "string",
"username": "string"
}
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------ |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [agentsdk.ExternalAuthResponse](schemas.md#agentsdkexternalauthresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Removed: Get workspace agent git auth
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/gitauth?match=string&id=string \
-H 'Accept: application/json' \ -H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY' -H 'Coder-Session-Token: API_KEY'
``` ```
@ -236,10 +279,11 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/gitauth?url=http%
### Parameters ### Parameters
| Name | In | Type | Required | Description | | Name | In | Type | Required | Description |
| -------- | ----- | ----------- | -------- | --------------------------------- | | -------- | ----- | ------- | -------- | --------------------------------- |
| `url` | query | string(uri) | true | Git URL | | `match` | query | string | true | Match |
| `listen` | query | boolean | false | Wait for a new token to be issued | | `id` | query | string | true | Provider ID |
| `listen` | query | boolean | false | Wait for a new token to be issued |
### Example responses ### Example responses
@ -247,7 +291,9 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/gitauth?url=http%
```json ```json
{ {
"access_token": "string",
"password": "string", "password": "string",
"type": "string",
"url": "string", "url": "string",
"username": "string" "username": "string"
} }
@ -255,9 +301,9 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/gitauth?url=http%
### Responses ### Responses
| Status | Meaning | Description | Schema | | Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------- | | ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------ |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [agentsdk.GitAuthResponse](schemas.md#agentsdkgitauthresponse) | | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [agentsdk.ExternalAuthResponse](schemas.md#agentsdkexternalauthresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md). To perform this operation, you must be authenticated. [Learn more](authentication.md).

16
docs/api/schemas.md generated
View File

@ -109,11 +109,13 @@
| `encoding` | string | true | | | | `encoding` | string | true | | |
| `signature` | string | true | | | | `signature` | string | true | | |
## agentsdk.GitAuthResponse ## agentsdk.ExternalAuthResponse
```json ```json
{ {
"access_token": "string",
"password": "string", "password": "string",
"type": "string",
"url": "string", "url": "string",
"username": "string" "username": "string"
} }
@ -121,11 +123,13 @@
### Properties ### Properties
| Name | Type | Required | Restrictions | Description | | Name | Type | Required | Restrictions | Description |
| ---------- | ------ | -------- | ------------ | ----------- | | -------------- | ------ | -------- | ------------ | ---------------------------------------------------------------------------------------- |
| `password` | string | false | | | | `access_token` | string | false | | |
| `url` | string | false | | | | `password` | string | false | | |
| `username` | string | false | | | | `type` | string | false | | |
| `url` | string | false | | |
| `username` | string | false | | Deprecated: Only supported on `/workspaceagents/me/gitauth` for backwards compatibility. |
## agentsdk.GitSSHKey ## agentsdk.GitSSHKey

View File

@ -29,6 +29,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr
| [<code>create</code>](./cli/create.md) | Create a workspace | | [<code>create</code>](./cli/create.md) | Create a workspace |
| [<code>delete</code>](./cli/delete.md) | Delete a workspace | | [<code>delete</code>](./cli/delete.md) | Delete a workspace |
| [<code>dotfiles</code>](./cli/dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | | [<code>dotfiles</code>](./cli/dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository |
| [<code>external-auth</code>](./cli/external-auth.md) | Manage external authentication |
| [<code>features</code>](./cli/features.md) | List Enterprise features | | [<code>features</code>](./cli/features.md) | List Enterprise features |
| [<code>groups</code>](./cli/groups.md) | Manage groups | | [<code>groups</code>](./cli/groups.md) | Manage groups |
| [<code>licenses</code>](./cli/licenses.md) | Add, delete, and list licenses | | [<code>licenses</code>](./cli/licenses.md) | Add, delete, and list licenses |

23
docs/cli/external-auth.md generated Normal file
View File

@ -0,0 +1,23 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# external-auth
Manage external authentication
## Usage
```console
coder external-auth
```
## Description
```console
Authenticate with external services inside of a workspace.
```
## Subcommands
| Name | Purpose |
| ------------------------------------------------------------ | ----------------------------------- |
| [<code>access-token</code>](./external-auth_access-token.md) | Print auth for an external provider |

39
docs/cli/external-auth_access-token.md generated Normal file
View File

@ -0,0 +1,39 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# external-auth access-token
Print auth for an external provider
## Usage
```console
coder external-auth access-token [flags] <provider>
```
## Description
```console
Print an access-token for an external auth provider. The access-token will be validated and sent to stdout with exit code 0. If a valid access-token cannot be obtained, the URL to authenticate will be sent to stdout with exit code 1
- Ensure that the user is authenticated with GitHub before cloning.:
$ #!/usr/bin/env sh
OUTPUT=$(coder external-auth access-token github)
if [ $? -eq 0 ]; then
echo "Authenticated with GitHub"
else
echo "Please authenticate with GitHub:"
echo $OUTPUT
fi
```
## Options
### --s
| | |
| ---- | ----------------- |
| Type | <code>bool</code> |
Do not print the URL or access token.

View File

@ -557,6 +557,16 @@
"description": "Personalize your workspace by applying a canonical dotfiles repository", "description": "Personalize your workspace by applying a canonical dotfiles repository",
"path": "cli/dotfiles.md" "path": "cli/dotfiles.md"
}, },
{
"title": "external-auth",
"description": "Manage external authentication",
"path": "cli/external-auth.md"
},
{
"title": "external-auth access-token",
"description": "Print auth for an external provider",
"path": "cli/external-auth_access-token.md"
},
{ {
"title": "features", "title": "features",
"description": "List Enterprise features", "description": "List Enterprise features",

View File

@ -661,28 +661,29 @@ func ConvertState(modules []*tfjson.StateModule, rawGraph string) (*State, error
} }
// A map is used to ensure we don't have duplicates! // A map is used to ensure we don't have duplicates!
gitAuthProvidersMap := map[string]struct{}{} externalAuthProvidersMap := map[string]struct{}{}
for _, tfResources := range tfResourcesByLabel { for _, tfResources := range tfResourcesByLabel {
for _, resource := range tfResources { for _, resource := range tfResources {
if resource.Type != "coder_git_auth" { // Checking for `coder_git_auth` is legacy!
if resource.Type != "coder_external_auth" && resource.Type != "coder_git_auth" {
continue continue
} }
id, ok := resource.AttributeValues["id"].(string) id, ok := resource.AttributeValues["id"].(string)
if !ok { if !ok {
return nil, xerrors.Errorf("git auth id is not a string") return nil, xerrors.Errorf("external auth id is not a string")
} }
gitAuthProvidersMap[id] = struct{}{} externalAuthProvidersMap[id] = struct{}{}
} }
} }
gitAuthProviders := make([]string, 0, len(gitAuthProvidersMap)) externalAuthProviders := make([]string, 0, len(externalAuthProvidersMap))
for id := range gitAuthProvidersMap { for id := range externalAuthProvidersMap {
gitAuthProviders = append(gitAuthProviders, id) externalAuthProviders = append(externalAuthProviders, id)
} }
return &State{ return &State{
Resources: resources, Resources: resources,
Parameters: parameters, Parameters: parameters,
ExternalAuthProviders: gitAuthProviders, ExternalAuthProviders: externalAuthProviders,
}, nil }, nil
} }