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

View File

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

View File

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

View File

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

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": {
"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"
}
}

View File

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

View File

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

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) {
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.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)
})
}

View File

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

View File

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

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

16
docs/api/schemas.md generated
View File

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

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>delete</code>](./cli/delete.md) | Delete a workspace |
| [<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>groups</code>](./cli/groups.md) | Manage groups |
| [<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",
"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",

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