From 35538e1051e717fb9342d27f7225f39a1f5800e2 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 9 Oct 2023 18:04:35 -0500 Subject: [PATCH] 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 --- cli/externalauth.go | 91 +++++++++++++++++ cli/externalauth_test.go | 49 ++++++++++ cli/gitaskpass.go | 10 +- cli/gitaskpass_test.go | 8 +- cli/root.go | 1 + cli/testdata/coder_--help.golden | 1 + .../coder_external-auth_--help.golden | 14 +++ ...r_external-auth_access-token_--help.golden | 28 ++++++ coderd/apidoc/docs.go | 76 ++++++++++++-- coderd/apidoc/swagger.json | 72 ++++++++++++-- coderd/coderd.go | 2 + coderd/deprecated.go | 14 +++ coderd/externalauth_test.go | 43 +++++--- coderd/workspaceagents.go | 98 ++++++++++++------- codersdk/agentsdk/agentsdk.go | 41 ++++++-- docs/api/agents.md | 64 ++++++++++-- docs/api/schemas.md | 16 +-- docs/cli.md | 1 + docs/cli/external-auth.md | 23 +++++ docs/cli/external-auth_access-token.md | 39 ++++++++ docs/manifest.json | 10 ++ provisioner/terraform/resources.go | 17 ++-- 22 files changed, 613 insertions(+), 105 deletions(-) create mode 100644 cli/externalauth.go create mode 100644 cli/externalauth_test.go create mode 100644 cli/testdata/coder_external-auth_--help.golden create mode 100644 cli/testdata/coder_external-auth_access-token_--help.golden create mode 100644 docs/cli/external-auth.md create mode 100644 docs/cli/external-auth_access-token.md diff --git a/cli/externalauth.go b/cli/externalauth.go new file mode 100644 index 0000000000..5d2cdfa82b --- /dev/null +++ b/cli/externalauth.go @@ -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 ", + 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 + }, + } +} diff --git a/cli/externalauth_test.go b/cli/externalauth_test.go new file mode 100644 index 0000000000..3af933e888 --- /dev/null +++ b/cli/externalauth_test.go @@ -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") + }) +} diff --git a/cli/gitaskpass.go b/cli/gitaskpass.go index b7636494c3..83ac98094e 100644 --- a/cli/gitaskpass.go +++ b/cli/gitaskpass.go @@ -13,6 +13,7 @@ import ( "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/cli/gitauth" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/retry" ) @@ -38,7 +39,9 @@ func (r *RootCmd) gitAskpass() *clibase.Cmd { 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 { var apiError *codersdk.Error 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); { - token, err = client.GitAuth(ctx, host, true) + token, err = client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{ + Match: host, + Listen: true, + }) if err != nil { continue } diff --git a/cli/gitaskpass_test.go b/cli/gitaskpass_test.go index 5ec7f4c6bb..92fe3943c1 100644 --- a/cli/gitaskpass_test.go +++ b/cli/gitaskpass_test.go @@ -23,7 +23,7 @@ func TestGitAskpass(t *testing.T) { t.Run("UsernameAndPassword", 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.GitAuthResponse{ + httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{ Username: "something", Password: "bananas", }) @@ -65,8 +65,8 @@ func TestGitAskpass(t *testing.T) { t.Run("Poll", func(t *testing.T) { t.Parallel() - resp := atomic.Pointer[agentsdk.GitAuthResponse]{} - resp.Store(&agentsdk.GitAuthResponse{ + resp := atomic.Pointer[agentsdk.ExternalAuthResponse]{} + resp.Store(&agentsdk.ExternalAuthResponse{ URL: "https://something.org", }) poll := make(chan struct{}, 10) @@ -96,7 +96,7 @@ func TestGitAskpass(t *testing.T) { }() <-poll stderr.ExpectMatch("Open the following URL to authenticate") - resp.Store(&agentsdk.GitAuthResponse{ + resp.Store(&agentsdk.ExternalAuthResponse{ Username: "username", Password: "password", }) diff --git a/cli/root.go b/cli/root.go index d75187d3f4..d2eea4b378 100644 --- a/cli/root.go +++ b/cli/root.go @@ -83,6 +83,7 @@ func (r *RootCmd) Core() []*clibase.Cmd { // Please re-sort this list alphabetically if you change it! return []*clibase.Cmd{ r.dotfiles(), + r.externalAuth(), r.login(), r.logout(), r.netcheck(), diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index d44c487bfd..d04546ce01 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -20,6 +20,7 @@ SUBCOMMANDS: delete Delete a workspace dotfiles Personalize your workspace by applying a canonical dotfiles repository + external-auth Manage external authentication list List workspaces login Authenticate with Coder deployment logout Unauthenticate your local session diff --git a/cli/testdata/coder_external-auth_--help.golden b/cli/testdata/coder_external-auth_--help.golden new file mode 100644 index 0000000000..42b465068d --- /dev/null +++ b/cli/testdata/coder_external-auth_--help.golden @@ -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. diff --git a/cli/testdata/coder_external-auth_access-token_--help.golden b/cli/testdata/coder_external-auth_access-token_--help.golden new file mode 100644 index 0000000000..9a623a042c --- /dev/null +++ b/cli/testdata/coder_external-auth_access-token_--help.golden @@ -0,0 +1,28 @@ +coder v0.0.0-devel + +USAGE: + coder external-auth access-token [flags] + + 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. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index bb7dd87636..3c4ebc46c4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4561,7 +4561,7 @@ const docTemplate = `{ } } }, - "/workspaceagents/me/gitauth": { + "/workspaceagents/me/external-auth": { "get": { "security": [ { @@ -4574,14 +4574,20 @@ const docTemplate = `{ "tags": [ "Agents" ], - "summary": "Get workspace agent Git auth", - "operationId": "get-workspace-agent-git-auth", + "summary": "Get workspace agent external auth", + "operationId": "get-workspace-agent-external-auth", "parameters": [ { "type": "string", - "format": "uri", - "description": "Git URL", - "name": "url", + "description": "Match", + "name": "match", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Provider ID", + "name": "id", "in": "query", "required": true }, @@ -4596,7 +4602,54 @@ const docTemplate = `{ "200": { "description": "OK", "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", "properties": { + "access_token": { + "type": "string" + }, "password": { "type": "string" }, + "type": { + "type": "string" + }, "url": { "type": "string" }, "username": { + "description": "Deprecated: Only supported on ` + "`" + `/workspaceagents/me/gitauth` + "`" + `\nfor backwards compatibility.", "type": "string" } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9e26d94e00..285d9ef4f6 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4011,7 +4011,7 @@ } } }, - "/workspaceagents/me/gitauth": { + "/workspaceagents/me/external-auth": { "get": { "security": [ { @@ -4020,14 +4020,20 @@ ], "produces": ["application/json"], "tags": ["Agents"], - "summary": "Get workspace agent Git auth", - "operationId": "get-workspace-agent-git-auth", + "summary": "Get workspace agent external auth", + "operationId": "get-workspace-agent-external-auth", "parameters": [ { "type": "string", - "format": "uri", - "description": "Git URL", - "name": "url", + "description": "Match", + "name": "match", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Provider ID", + "name": "id", "in": "query", "required": true }, @@ -4042,7 +4048,50 @@ "200": { "description": "OK", "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", "properties": { + "access_token": { + "type": "string" + }, "password": { "type": "string" }, + "type": { + "type": "string" + }, "url": { "type": "string" }, "username": { + "description": "Deprecated: Only supported on `/workspaceagents/me/gitauth`\nfor backwards compatibility.", "type": "string" } } diff --git a/coderd/coderd.go b/coderd/coderd.go index d46cb493dc..cabf63c34b 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -812,7 +812,9 @@ func New(options *Options) *API { r.Patch("/startup-logs", api.patchWorkspaceAgentLogsDeprecated) r.Patch("/logs", api.patchWorkspaceAgentLogs) r.Post("/app-health", api.postWorkspaceAppHealth) + // Deprecated: Required to support legacy agents r.Get("/gitauth", api.workspaceAgentsGitAuth) + r.Get("/external-auth", api.workspaceAgentsExternalAuth) r.Get("/gitsshkey", api.agentGitSSHKey) r.Get("/coordinate", api.workspaceAgentCoordinate) r.Post("/report-stats", api.workspaceAgentReportStats) diff --git a/coderd/deprecated.go b/coderd/deprecated.go index f656451a83..0b7b0b14a2 100644 --- a/coderd/deprecated.go +++ b/coderd/deprecated.go @@ -56,3 +56,17 @@ func (api *API) patchWorkspaceAgentLogsDeprecated(rw http.ResponseWriter, r *htt func (api *API) workspaceAgentLogsDeprecated(rw http.ResponseWriter, r *http.Request) { 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) +} diff --git a/coderd/externalauth_test.go b/coderd/externalauth_test.go index df4e092e05..9ba18b2c0f 100644 --- a/coderd/externalauth_test.go +++ b/coderd/externalauth_test.go @@ -132,7 +132,7 @@ func TestExternalAuthByID(t *testing.T) { }) } -func TestGitAuthDevice(t *testing.T) { +func TestExternalAuthDevice(t *testing.T) { t.Parallel() t.Run("NotSupported", func(t *testing.T) { t.Parallel() @@ -214,7 +214,7 @@ func TestGitAuthDevice(t *testing.T) { } // nolint:bodyclose -func TestGitAuthCallback(t *testing.T) { +func TestExternalAuthCallback(t *testing.T) { t.Parallel() t.Run("NoMatchingConfig", func(t *testing.T) { t.Parallel() @@ -236,7 +236,9 @@ func TestGitAuthCallback(t *testing.T) { agentClient := agentsdk.New(client.URL) 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 require.ErrorAs(t, err, &apiError) require.Equal(t, http.StatusNotFound, apiError.StatusCode()) @@ -266,7 +268,9 @@ func TestGitAuthCallback(t *testing.T) { agentClient := agentsdk.New(client.URL) 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.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) { 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.NotEmpty(t, res.URL) @@ -355,7 +361,9 @@ func TestGitAuthCallback(t *testing.T) { w.WriteHeader(http.StatusForbidden) 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 require.ErrorAs(t, err, &apiError) require.Equal(t, http.StatusInternalServerError, apiError.StatusCode()) @@ -395,7 +403,9 @@ func TestGitAuthCallback(t *testing.T) { agentClient := agentsdk.New(client.URL) 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.NotEmpty(t, token.URL) @@ -407,7 +417,9 @@ func TestGitAuthCallback(t *testing.T) { // Because the token is expired and `NoRefresh` is specified, // 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.NotEmpty(t, token.URL) }) @@ -438,14 +450,19 @@ func TestGitAuthCallback(t *testing.T) { agentClient := agentsdk.New(client.URL) 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.NotEmpty(t, token.URL) // Start waiting for the token callback... - tokenChan := make(chan agentsdk.GitAuthResponse, 1) + tokenChan := make(chan agentsdk.ExternalAuthResponse, 1) 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) tokenChan <- token }() @@ -457,7 +474,9 @@ func TestGitAuthCallback(t *testing.T) { token = <-tokenChan 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) }) } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 6c8eed5734..a5e7856286 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -225,13 +225,20 @@ func (api *API) workspaceAgentManifest(rw http.ResponseWriter, r *http.Request) 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{ AgentID: apiAgent.ID, Apps: convertApps(dbApps, workspaceAgent, owner, workspace), Scripts: convertScripts(scripts), DERPMap: api.DERPMap(), DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(), - GitAuthConfigs: len(api.ExternalAuthConfigs), + GitAuthConfigs: gitAuthConfigs, EnvironmentVariables: apiAgent.EnvironmentVariables, Directory: apiAgent.Directory, VSCodePortProxyURI: vscodeProxyURI, @@ -2155,44 +2162,62 @@ func (api *API) postWorkspaceAppHealth(rw http.ResponseWriter, r *http.Request) httpapi.Write(ctx, rw, http.StatusOK, nil) } -// workspaceAgentsGitAuth returns a username and password for use -// with GIT_ASKPASS. +// workspaceAgentsExternalAuth returns an access token for a given URL +// or finds a provider by ID. // -// @Summary Get workspace agent Git auth -// @ID get-workspace-agent-git-auth +// @Summary Get workspace agent external auth +// @ID get-workspace-agent-external-auth // @Security CoderSessionToken // @Produce json // @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" -// @Success 200 {object} agentsdk.GitAuthResponse -// @Router /workspaceagents/me/gitauth [get] -func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) { +// @Success 200 {object} agentsdk.ExternalAuthResponse +// @Router /workspaceagents/me/external-auth [get] +func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - gitURL := r.URL.Query().Get("url") - if gitURL == "" { + // Either match or configID must be provided! + 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{ - Message: "Missing 'url' query parameter!", + Message: "'url' or 'id' must be provided!", }) 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 // new token to be issued! listen := r.URL.Query().Has("listen") var externalAuthConfig *externalauth.Config - for _, gitAuth := range api.ExternalAuthConfigs { - if gitAuth.Regex == nil { + for _, extAuth := range api.ExternalAuthConfigs { + if extAuth.ID == id { + externalAuthConfig = extAuth + break + } + if match == "" || extAuth.Regex == nil { continue } - matches := gitAuth.Regex.MatchString(gitURL) + matches := extAuth.Regex.MatchString(match) if !matches { continue } - externalAuthConfig = gitAuth + externalAuthConfig = extAuth } if externalAuthConfig == nil { - detail := "No external auth providers are configured." + detail := "External auth provider not found." if len(api.ExternalAuthConfigs) > 0 { regexURLs := make([]string, 0, len(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())) } - 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{ - 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, }) return } - enhancedType := codersdk.EnhancedExternalAuthProvider(externalAuthConfig.Type) - if !enhancedType.Git() { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "External auth provider does not support git.", - }) - return - } workspaceAgent := httpmw.WorkspaceAgent(r) // We must get the workspace to get the owner ID! resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID) @@ -2287,7 +2305,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) if !valid { continue } - httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(enhancedType, externalAuthLink.OAuthAccessToken)) + httpapi.Write(ctx, rw, http.StatusOK, createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken)) return } } @@ -2315,7 +2333,7 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) return } - httpapi.Write(ctx, rw, http.StatusOK, agentsdk.GitAuthResponse{ + httpapi.Write(ctx, rw, http.StatusOK, agentsdk.ExternalAuthResponse{ URL: redirectURL.String(), }) return @@ -2330,35 +2348,39 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) return } if !updated { - httpapi.Write(ctx, rw, http.StatusOK, agentsdk.GitAuthResponse{ + httpapi.Write(ctx, rw, http.StatusOK, agentsdk.ExternalAuthResponse{ URL: redirectURL.String(), }) 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. -func formatGitAuthAccessToken(typ codersdk.EnhancedExternalAuthProvider, token string) agentsdk.GitAuthResponse { - var resp agentsdk.GitAuthResponse +// createExternalAuthResponse creates an ExternalAuthResponse based on the +// provider type. This is to support legacy `/workspaceagents/me/gitauth` +// which uses `Username` and `Password`. +func createExternalAuthResponse(typ, token string) agentsdk.ExternalAuthResponse { + var resp agentsdk.ExternalAuthResponse switch typ { - case codersdk.EnhancedExternalAuthProviderGitLab: + case string(codersdk.EnhancedExternalAuthProviderGitLab): // https://stackoverflow.com/questions/25409700/using-gitlab-token-to-clone-without-authentication - resp = agentsdk.GitAuthResponse{ + resp = agentsdk.ExternalAuthResponse{ Username: "oauth2", 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 - resp = agentsdk.GitAuthResponse{ + resp = agentsdk.ExternalAuthResponse{ Username: "x-token-auth", Password: token, } default: - resp = agentsdk.GitAuthResponse{ + resp = agentsdk.ExternalAuthResponse{ Username: token, } } + resp.AccessToken = token + resp.Type = typ return resp } diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index c0750b1a22..2236bc8669 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -710,30 +710,51 @@ func (c *Client) GetServiceBanner(ctx context.Context) (codersdk.ServiceBannerCo 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"` 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 -func (c *Client) GitAuth(ctx context.Context, gitURL string, listen bool) (GitAuthResponse, error) { - reqURL := "/api/v2/workspaceagents/me/gitauth?url=" + url.QueryEscape(gitURL) - if listen { - reqURL += "&listen" +func (c *Client) ExternalAuth(ctx context.Context, req ExternalAuthRequest) (ExternalAuthResponse, error) { + q := url.Values{ + "id": []string{req.ID}, + "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) if err != nil { - return GitAuthResponse{}, xerrors.Errorf("execute request: %w", err) + return ExternalAuthResponse{}, xerrors.Errorf("execute request: %w", err) } defer res.Body.Close() 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) } diff --git a/docs/api/agents.md b/docs/api/agents.md index 326f523415..99d4509c4b 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -221,13 +221,56 @@ incoming connections and publishes node updates. To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get workspace agent Git auth +## Get workspace agent external auth ### Code samples ```shell # 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 'Coder-Session-Token: API_KEY' ``` @@ -236,10 +279,11 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/gitauth?url=http% ### Parameters -| Name | In | Type | Required | Description | -| -------- | ----- | ----------- | -------- | --------------------------------- | -| `url` | query | string(uri) | true | Git URL | -| `listen` | query | boolean | false | Wait for a new token to be issued | +| 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 @@ -247,7 +291,9 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/gitauth?url=http% ```json { + "access_token": "string", "password": "string", + "type": "string", "url": "string", "username": "string" } @@ -255,9 +301,9 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/gitauth?url=http% ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [agentsdk.GitAuthResponse](schemas.md#agentsdkgitauthresponse) | +| 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). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 926df93007..6fd8c6d1e8 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -109,11 +109,13 @@ | `encoding` | string | true | | | | `signature` | string | true | | | -## agentsdk.GitAuthResponse +## agentsdk.ExternalAuthResponse ```json { + "access_token": "string", "password": "string", + "type": "string", "url": "string", "username": "string" } @@ -121,11 +123,13 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -| ---------- | ------ | -------- | ------------ | ----------- | -| `password` | string | false | | | -| `url` | string | false | | | -| `username` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ---------------------------------------------------------------------------------------- | +| `access_token` | string | false | | | +| `password` | string | false | | | +| `type` | string | false | | | +| `url` | string | false | | | +| `username` | string | false | | Deprecated: Only supported on `/workspaceagents/me/gitauth` for backwards compatibility. | ## agentsdk.GitSSHKey diff --git a/docs/cli.md b/docs/cli.md index a63ccad623..57ce052fa4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -29,6 +29,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr | [create](./cli/create.md) | Create a workspace | | [delete](./cli/delete.md) | Delete a workspace | | [dotfiles](./cli/dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | +| [external-auth](./cli/external-auth.md) | Manage external authentication | | [features](./cli/features.md) | List Enterprise features | | [groups](./cli/groups.md) | Manage groups | | [licenses](./cli/licenses.md) | Add, delete, and list licenses | diff --git a/docs/cli/external-auth.md b/docs/cli/external-auth.md new file mode 100644 index 0000000000..ebe16435fe --- /dev/null +++ b/docs/cli/external-auth.md @@ -0,0 +1,23 @@ + + +# external-auth + +Manage external authentication + +## Usage + +```console +coder external-auth +``` + +## Description + +```console +Authenticate with external services inside of a workspace. +``` + +## Subcommands + +| Name | Purpose | +| ------------------------------------------------------------ | ----------------------------------- | +| [access-token](./external-auth_access-token.md) | Print auth for an external provider | diff --git a/docs/cli/external-auth_access-token.md b/docs/cli/external-auth_access-token.md new file mode 100644 index 0000000000..1ca1b32fe9 --- /dev/null +++ b/docs/cli/external-auth_access-token.md @@ -0,0 +1,39 @@ + + +# external-auth access-token + +Print auth for an external provider + +## Usage + +```console +coder external-auth access-token [flags] +``` + +## 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 | bool | + +Do not print the URL or access token. diff --git a/docs/manifest.json b/docs/manifest.json index c154a44606..f0af5d4176 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -557,6 +557,16 @@ "description": "Personalize your workspace by applying a canonical dotfiles repository", "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", "description": "List Enterprise features", diff --git a/provisioner/terraform/resources.go b/provisioner/terraform/resources.go index 36d494711d..77fb7e6f90 100644 --- a/provisioner/terraform/resources.go +++ b/provisioner/terraform/resources.go @@ -661,28 +661,29 @@ func ConvertState(modules []*tfjson.StateModule, rawGraph string) (*State, error } // A map is used to ensure we don't have duplicates! - gitAuthProvidersMap := map[string]struct{}{} + externalAuthProvidersMap := map[string]struct{}{} for _, tfResources := range tfResourcesByLabel { 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 } id, ok := resource.AttributeValues["id"].(string) 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)) - for id := range gitAuthProvidersMap { - gitAuthProviders = append(gitAuthProviders, id) + externalAuthProviders := make([]string, 0, len(externalAuthProvidersMap)) + for id := range externalAuthProvidersMap { + externalAuthProviders = append(externalAuthProviders, id) } return &State{ Resources: resources, Parameters: parameters, - ExternalAuthProviders: gitAuthProviders, + ExternalAuthProviders: externalAuthProviders, }, nil }