Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 43 additions & 18 deletions cli/externalauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ func externalAuth() *serpent.Command {
}

func externalAuthAccessToken() *serpent.Command {
var extra string
var (
extra string
outputFormat string
)
agentAuth := &AgentAuth{}
cmd := &serpent.Command{
Use: "access-token <provider>",
Expand All @@ -51,16 +54,29 @@ fi
Description: "Obtain an extra property of an access token for additional metadata.",
Command: "coder external-auth access-token slack --extra \"authed_user.id\"",
},
Example{
Description: "Print the full token response as JSON.",
Command: "coder external-auth access-token github --output json",
},
),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
),
Options: serpent.OptionSet{{
Name: "Extra",
Flag: "extra",
Description: "Extract a field from the \"extra\" properties of the OAuth token.",
Value: serpent.StringOf(&extra),
}},
Options: serpent.OptionSet{
{
Name: "Extra",
Flag: "extra",
Description: "Extract a field from the \"extra\" properties of the OAuth token.",
Value: serpent.StringOf(&extra),
},
{
Name: "Output",
Flag: "output",
Description: "Output format. Available formats: text, json.",
Value: serpent.EnumOf(&outputFormat, "text", "json"),
Default: "text",
},
},

Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
Expand All @@ -79,14 +95,21 @@ fi
if err != nil {
return xerrors.Errorf("get external auth token: %w", err)
}
if extAuth.URL != "" {
_, err = inv.Stdout.Write([]byte(extAuth.URL))

switch {
case outputFormat == "json":
data, err := json.MarshalIndent(extAuth, "", " ")
if err != nil {
return xerrors.Errorf("marshal external auth response: %w", err)
}
if _, err := inv.Stdout.Write(data); err != nil {
return err
}
return cliui.ErrCanceled
}
if extra != "" {
case extAuth.URL != "":
if _, err := inv.Stdout.Write([]byte(extAuth.URL)); err != nil {
return err
}
case extra != "":
if extAuth.TokenExtra == nil {
return xerrors.Errorf("no extra properties found for token")
}
Expand All @@ -95,15 +118,17 @@ fi
return xerrors.Errorf("marshal extra properties: %w", err)
}
result := gjson.GetBytes(data, extra)
_, err = inv.Stdout.Write([]byte(result.String()))
if err != nil {
if _, err := inv.Stdout.Write([]byte(result.String())); err != nil {
return err
}
default:
if _, err := inv.Stdout.Write([]byte(extAuth.AccessToken)); err != nil {
return err
}
return nil
}
_, err = inv.Stdout.Write([]byte(extAuth.AccessToken))
if err != nil {
return err

if extAuth.URL != "" {
return cliui.ErrCanceled
}
return nil
},
Expand Down
51 changes: 50 additions & 1 deletion cli/externalauth_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package cli_test

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/cli/cliui"
Expand Down Expand Up @@ -66,7 +71,7 @@ func TestExternalAuth(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
AccessToken: "bananas",
TokenExtra: map[string]interface{}{
TokenExtra: map[string]any{
"hey": "there",
},
})
Expand All @@ -78,4 +83,48 @@ func TestExternalAuth(t *testing.T) {
clitest.Start(t, inv)
stdout.ExpectMatch(ctx, "there")
})
t.Run("JSONOutput", func(t *testing.T) {
t.Parallel()
expiry := time.Now().Add(8 * time.Hour).UTC().Truncate(time.Second)

tests := []struct {
name string
resp agentsdk.ExternalAuthResponse
wantErr error
}{
{
name: "WithExpiry",
resp: agentsdk.ExternalAuthResponse{AccessToken: "bananas", ExpiresAt: expiry},
},
{
name: "WithURL",
resp: agentsdk.ExternalAuthResponse{URL: "https://github.com/login"},
wantErr: cliui.ErrCanceled,
},
}
for _, tt := range tests {
t.Run(tt.name, 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, tt.resp)
}))
t.Cleanup(srv.Close)
inv, _ := clitest.New(t, "--agent-url", srv.URL, "--agent-token", "foo", "external-auth", "access-token", "github", "--output", "json")
buf := new(bytes.Buffer)
inv.Stdout = buf
waiter := clitest.StartWithWaiter(t, inv)
if tt.wantErr != nil {
waiter.RequireIs(tt.wantErr)
} else {
waiter.RequireSuccess()
}

var resp agentsdk.ExternalAuthResponse
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
require.Equal(t, tt.resp.AccessToken, resp.AccessToken)
require.Equal(t, tt.resp.URL, resp.URL)
require.Equal(t, tt.resp.ExpiresAt.UTC(), resp.ExpiresAt.UTC())
})
}
})
}
7 changes: 7 additions & 0 deletions cli/testdata/coder_external-auth_access-token_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ USAGE:
- Obtain an extra property of an access token for additional metadata.:

$ coder external-auth access-token slack --extra "authed_user.id"

- Print the full token response as JSON.:

$ coder external-auth access-token github --output json

OPTIONS:
--auth string, $CODER_AGENT_AUTH (default: token)
Expand All @@ -44,5 +48,8 @@ OPTIONS:
--extra string
Extract a field from the "extra" properties of the OAuth token.

--output text|json (default: text)
Output format. Available formats: text, json.

———
Run `coder --help` for a list of global options.
4 changes: 4 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions coderd/workspaceagents.go
Original file line number Diff line number Diff line change
Expand Up @@ -2135,7 +2135,7 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ
})
return
}
resp, err := createExternalAuthResponse(externalAuthConfig.Type, refreshedLink.OAuthAccessToken, refreshedLink.OAuthExtra)
resp, err := createExternalAuthResponse(externalAuthConfig.Type, refreshedLink.OAuthAccessToken, refreshedLink.OAuthExtra, refreshedLink.OAuthExpiry)
if err != nil {
handleRetrying(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create external auth response.",
Expand Down Expand Up @@ -2208,7 +2208,7 @@ func (api *API) workspaceAgentsExternalAuthListen(ctx context.Context, rw http.R
if !valid {
continue
}
resp, err := createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken, externalAuthLink.OAuthExtra)
resp, err := createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken, externalAuthLink.OAuthExtra, externalAuthLink.OAuthExpiry)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create external auth response.",
Expand Down Expand Up @@ -2375,7 +2375,7 @@ func fillCoderDesktopTelemetry(r *http.Request, event *telemetry.UserTailnetConn
// 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, extra pqtype.NullRawMessage) (agentsdk.ExternalAuthResponse, error) {
func createExternalAuthResponse(typ, token string, extra pqtype.NullRawMessage, expiry time.Time) (agentsdk.ExternalAuthResponse, error) {
var resp agentsdk.ExternalAuthResponse
switch typ {
case string(codersdk.EnhancedExternalAuthProviderGitLab):
Expand All @@ -2398,6 +2398,10 @@ func createExternalAuthResponse(typ, token string, extra pqtype.NullRawMessage)
}
resp.AccessToken = token
resp.Type = typ
// Normalize to UTC so JSON encoding always uses the "Z" suffix and
// preserves the full timestamp without losing sub-minute precision from
// historical timezone offsets (e.g. LMT).
resp.ExpiresAt = expiry.UTC()

var err error
if extra.Valid {
Expand Down
112 changes: 112 additions & 0 deletions coderd/workspaceagents_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import (
"strings"
"sync"
"testing"
"time"

"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"
Expand All @@ -32,6 +34,7 @@ import (
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock"
"github.com/coder/coder/v2/codersdk/wsjson"
Expand Down Expand Up @@ -979,3 +982,112 @@ func TestWatchAgentContainers(t *testing.T) {
}
})
}

func TestCreateExternalAuthResponse(t *testing.T) {
t.Parallel()

// Use a fixed future time.
expiry := dbtime.Now().Add(8 * time.Hour).UTC()

assertExpiry := func(t *testing.T, resp agentsdk.ExternalAuthResponse, want time.Time) {
t.Helper()
require.Equal(t, want.UTC(), resp.ExpiresAt.UTC(),
"ExpiresAt should match the expiry passed to createExternalAuthResponse")
}

t.Run("WithExpiry", func(t *testing.T) {
t.Parallel()

resp, err := createExternalAuthResponse("github", "tok", pqtype.NullRawMessage{}, expiry)
require.NoError(t, err)
assertExpiry(t, resp, expiry)
require.Equal(t, "tok", resp.AccessToken)
})

t.Run("ZeroExpiry", func(t *testing.T) {
t.Parallel()

// A zero expiry means the token never expires. ExpiresAt should stay zero.
resp, err := createExternalAuthResponse("github", "tok", pqtype.NullRawMessage{}, time.Time{})
require.NoError(t, err)
require.True(t, resp.ExpiresAt.IsZero(), "ExpiresAt should be zero when no expiry is set")
})

// Each provider type maps the token into a different Username/Password pair.
// All of them must also carry ExpiresAt through unchanged.
providerTests := []struct {
name string
typ string
token string
wantUsername string
wantPassword string
}{
{
name: "GitHub",
typ: codersdk.EnhancedExternalAuthProviderGitHub.String(),
token: "ghtoken",
wantUsername: "ghtoken",
wantPassword: "",
},
{
name: "GitLab",
typ: codersdk.EnhancedExternalAuthProviderGitLab.String(),
token: "gltoken",
wantUsername: "oauth2",
wantPassword: "gltoken",
},
{
name: "BitbucketCloud",
typ: codersdk.EnhancedExternalAuthProviderBitBucketCloud.String(),
token: "bbtoken",
wantUsername: "x-token-auth",
wantPassword: "bbtoken",
},
{
name: "BitbucketServer",
typ: codersdk.EnhancedExternalAuthProviderBitBucketServer.String(),
token: "bbtoken",
wantUsername: "x-token-auth",
wantPassword: "bbtoken",
},
}
for _, tt := range providerTests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

resp, err := createExternalAuthResponse(tt.typ, tt.token, pqtype.NullRawMessage{}, expiry)
require.NoError(t, err)
require.Equal(t, tt.wantUsername, resp.Username)
require.Equal(t, tt.wantPassword, resp.Password)
require.Equal(t, tt.token, resp.AccessToken)
assertExpiry(t, resp, expiry)
})
}

t.Run("WithTokenExtra", func(t *testing.T) {
t.Parallel()

extra := pqtype.NullRawMessage{
RawMessage: []byte(`{"user_id":"u_42","scope":"repo"}`),
Valid: true,
}
resp, err := createExternalAuthResponse("slack", "slacktoken", extra, expiry)
require.NoError(t, err)
require.Equal(t, "u_42", resp.TokenExtra["user_id"])
require.Equal(t, "repo", resp.TokenExtra["scope"])
assertExpiry(t, resp, expiry)
})
Comment thread
BobbyHo marked this conversation as resolved.

t.Run("InvalidExtraJSON", func(t *testing.T) {
t.Parallel()

// Malformed JSON in the extra field should produce an error but
// ExpiresAt should still reflect the expiry that was passed in.
extra := pqtype.NullRawMessage{
RawMessage: []byte(`not-valid-json`),
Valid: true,
}
_, err := createExternalAuthResponse("github", "tok", extra, expiry)
require.Error(t, err, "malformed extra JSON should produce an error")
})
}
Loading
Loading