From 3cab23382b59c2a4ea7b641e8888d6dcc83e8d43 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 30 Jun 2026 11:48:20 -0700 Subject: [PATCH 01/10] feat: expose external auth token expiry in agent API and CLI Surface OAuthExpiry from the database through ExternalAuthResponse so workspace agents and credential helpers can cache tokens until the right moment rather than round-tripping to coderd on every git operation. Adds ExpiresAt to ExternalAuthResponse, threads the expiry through createExternalAuthResponse (normalizing to UTC to avoid sub-minute LMT timezone precision loss in JSON encoding), and exposes it via --output json on coder external-auth access-token. --- cli/externalauth.go | 46 +++++++-- cli/externalauth_test.go | 44 ++++++++- coderd/workspaceagents.go | 10 +- coderd/workspaceagents_internal_test.go | 121 ++++++++++++++++++++++++ coderd/workspaceagents_test.go | 104 ++++++++++++++++++++ codersdk/agentsdk/agentsdk.go | 2 + 6 files changed, 316 insertions(+), 11 deletions(-) diff --git a/cli/externalauth.go b/cli/externalauth.go index d235e7b0d752b..970ec8eb248aa 100644 --- a/cli/externalauth.go +++ b/cli/externalauth.go @@ -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 ", @@ -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, including expiry.", + 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() @@ -79,6 +95,22 @@ fi if err != nil { return xerrors.Errorf("get external auth token: %w", err) } + + if outputFormat == "json" { + data, err := json.MarshalIndent(extAuth, "", " ") + if err != nil { + return xerrors.Errorf("marshal external auth response: %w", err) + } + _, err = inv.Stdout.Write(data) + if err != nil { + return err + } + if extAuth.URL != "" { + return cliui.ErrCanceled + } + return nil + } + if extAuth.URL != "" { _, err = inv.Stdout.Write([]byte(extAuth.URL)) if err != nil { diff --git a/cli/externalauth_test.go b/cli/externalauth_test.go index 614505f309f47..ab5e91581465e 100644 --- a/cli/externalauth_test.go +++ b/cli/externalauth_test.go @@ -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" @@ -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", }, }) @@ -78,4 +83,41 @@ func TestExternalAuth(t *testing.T) { clitest.Start(t, inv) stdout.ExpectMatch(ctx, "there") }) + t.Run("JSONOutputWithExpiry", func(t *testing.T) { + t.Parallel() + expiry := time.Now().Add(8 * time.Hour).UTC().Truncate(time.Second) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{ + AccessToken: "bananas", + ExpiresAt: expiry, + }) + })) + 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 + clitest.StartWithWaiter(t, inv).RequireSuccess() + + var resp agentsdk.ExternalAuthResponse + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + require.Equal(t, "bananas", resp.AccessToken) + require.Equal(t, expiry, resp.ExpiresAt.UTC().Truncate(time.Second)) + }) + t.Run("JSONOutputWithURL", 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/login", + }) + })) + 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 + clitest.StartWithWaiter(t, inv).RequireIs(cliui.ErrCanceled) + + var resp agentsdk.ExternalAuthResponse + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + require.Equal(t, "https://github.com/login", resp.URL) + }) } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 915cd2ac909ba..3a7fddfb61e72 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -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.", @@ -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.", @@ -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): @@ -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 { diff --git a/coderd/workspaceagents_internal_test.go b/coderd/workspaceagents_internal_test.go index f7f9ff5954201..b879bb9f8cff3 100644 --- a/coderd/workspaceagents_internal_test.go +++ b/coderd/workspaceagents_internal_test.go @@ -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" @@ -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" @@ -979,3 +982,121 @@ func TestWatchAgentContainers(t *testing.T) { } }) } + +func TestCreateExternalAuthResponse(t *testing.T) { + t.Parallel() + + // Use a fixed future time, truncated to seconds to survive JSON round-trips. + expiry := dbtime.Now().Add(8 * time.Hour).UTC().Truncate(time.Second) + + assertExpiry := func(t *testing.T, resp agentsdk.ExternalAuthResponse, want time.Time) { + t.Helper() + require.Equal(t, want.UTC(), resp.ExpiresAt.UTC().Truncate(time.Second), + "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. + t.Run("GitHub", func(t *testing.T) { + t.Parallel() + + resp, err := createExternalAuthResponse( + codersdk.EnhancedExternalAuthProviderGitHub.String(), "ghtoken", + pqtype.NullRawMessage{}, expiry, + ) + require.NoError(t, err) + // GitHub tokens are placed in Username, Password is empty. + require.Equal(t, "ghtoken", resp.Username) + require.Empty(t, resp.Password) + require.Equal(t, "ghtoken", resp.AccessToken) + assertExpiry(t, resp, expiry) + }) + + t.Run("GitLab", func(t *testing.T) { + t.Parallel() + + resp, err := createExternalAuthResponse( + codersdk.EnhancedExternalAuthProviderGitLab.String(), "gltoken", + pqtype.NullRawMessage{}, expiry, + ) + require.NoError(t, err) + // GitLab uses oauth2/token as the credential pair. + require.Equal(t, "oauth2", resp.Username) + require.Equal(t, "gltoken", resp.Password) + require.Equal(t, "gltoken", resp.AccessToken) + assertExpiry(t, resp, expiry) + }) + + t.Run("BitbucketCloud", func(t *testing.T) { + t.Parallel() + + resp, err := createExternalAuthResponse( + codersdk.EnhancedExternalAuthProviderBitBucketCloud.String(), "bbtoken", + pqtype.NullRawMessage{}, expiry, + ) + require.NoError(t, err) + require.Equal(t, "x-token-auth", resp.Username) + require.Equal(t, "bbtoken", resp.Password) + require.Equal(t, "bbtoken", resp.AccessToken) + assertExpiry(t, resp, expiry) + }) + + t.Run("BitbucketServer", func(t *testing.T) { + t.Parallel() + + resp, err := createExternalAuthResponse( + codersdk.EnhancedExternalAuthProviderBitBucketServer.String(), "bbtoken", + pqtype.NullRawMessage{}, expiry, + ) + require.NoError(t, err) + require.Equal(t, "x-token-auth", resp.Username) + require.Equal(t, "bbtoken", resp.Password) + require.Equal(t, "bbtoken", 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) + }) + + 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") + }) +} diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 1e52d1e35cff5..65c7eb1dbdf6d 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "slices" "strings" "sync" @@ -25,6 +26,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "golang.org/x/oauth2" "golang.org/x/xerrors" "google.golang.org/protobuf/types/known/timestamppb" "tailscale.com/tailcfg" @@ -3698,3 +3700,105 @@ func (p *pubsubReinitSpy) Subscribe(event string, listener pubsub.Listener) (can p.Unlock() return cancel, err } + +// TestWorkspaceAgentsExternalAuthExpiresAt verifies that the expiry stored on +// an ExternalAuthLink is returned in ExternalAuthResponse.ExpiresAt via the +// full HTTP round-trip, covering both a non-zero and zero expiry. +func TestWorkspaceAgentsExternalAuthExpiresAt(t *testing.T) { + t.Parallel() + + const providerID = "test-provider" + + // seedToken is both the access token value stored in the DB and the one + // the fake OAuth2 provider returns. When they match, RefreshToken detects + // no change and skips the DB update, preserving the seeded OAuthExpiry. + const seedToken = "seed-token" + + // newSetup creates a coderdtest server with a minimal external-auth + // provider that has no ValidateURL (all tokens accepted as valid) and + // returns seedToken so that RefreshToken does not overwrite the link. + newSetup := func(t *testing.T) (agentToken string, agentClient *agentsdk.Client, db database.Store, ownerID uuid.UUID) { + t.Helper() + + ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{ + // Return seedToken so token.AccessToken == originalAccessToken + // in RefreshToken, preventing a DB update that would overwrite + // the seeded OAuthExpiry. + Token: &oauth2.Token{ + AccessToken: seedToken, + RefreshToken: "refresh-token", + Expiry: dbtime.Now().Add(24 * time.Hour), + }, + }, + ID: providerID, + Regex: regexp.MustCompile(`.*`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + // ValidateURL intentionally omitted: tokens are always valid. + }}, + }) + first := coderdtest.CreateFirstUser(t, ownerClient) + _, user := coderdtest.CreateAnotherUser(t, ownerClient, first.OrganizationID) + + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: first.OrganizationID, + OwnerID: user.ID, + }).WithAgent().Do() + + ac := agentsdk.New(ownerClient.URL, agentsdk.WithFixedToken(r.AgentToken)) + return r.AgentToken, ac, db, user.ID + } + + t.Run("NonZeroExpiry", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + _, agentClient, db, userID := newSetup(t) + + // Seed a link with an 8-hour expiry and verify the response carries it. + want := dbtime.Now().Add(8 * time.Hour).UTC().Truncate(time.Second) + dbgen.ExternalAuthLink(t, db, database.ExternalAuthLink{ + ProviderID: providerID, + UserID: userID, + OAuthAccessToken: seedToken, + OAuthExpiry: want, + }) + + resp, err := agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{ + ID: providerID, + }) + require.NoError(t, err) + require.Empty(t, resp.URL, "token should be valid, no redirect URL expected") + require.Equal(t, want, resp.ExpiresAt.UTC().Truncate(time.Second), + "ExpiresAt should match the expiry stored in the database") + }) + + t.Run("ZeroExpiry", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + _, agentClient, db, userID := newSetup(t) + + // dbgen.ExternalAuthLink uses takeFirst which skips zero time.Time + // values and fills in a 24-hour default. Insert the link directly to + // store an explicit zero OAuthExpiry (token never expires). + _, err := db.InsertExternalAuthLink(dbauthz.AsSystemRestricted(ctx), database.InsertExternalAuthLinkParams{ + ProviderID: providerID, + UserID: userID, + OAuthAccessToken: seedToken, + OAuthExpiry: time.Time{}, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + resp, err := agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{ + ID: providerID, + }) + require.NoError(t, err) + require.Empty(t, resp.URL) + require.True(t, resp.ExpiresAt.IsZero(), + "ExpiresAt should be zero when the token has no expiry") + }) +} diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 76ca939453741..0be1cd031a650 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -724,6 +724,8 @@ type ExternalAuthResponse struct { TokenExtra map[string]interface{} `json:"token_extra"` URL string `json:"url"` Type string `json:"type"` + // ExpiresAt is the time the token expires. Zero value means no expiry. + ExpiresAt time.Time `json:"expires_at"` // Deprecated: Only supported on `/workspaceagents/me/gitauth` // for backwards compatibility. From 2b363701364e4d97e14a83e1bb07b89b3183096a Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 30 Jun 2026 12:24:20 -0700 Subject: [PATCH 02/10] chore: regenerate docs for external auth expiry and --output flag --- coderd/apidoc/docs.go | 4 ++++ coderd/apidoc/swagger.json | 4 ++++ docs/reference/api/agents.md | 2 ++ docs/reference/api/schemas.md | 2 ++ docs/reference/cli/external-auth_access-token.md | 13 +++++++++++++ 5 files changed, 25 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index fdbdb9f46dddf..11f6a07b177ed 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -14631,6 +14631,10 @@ const docTemplate = `{ "access_token": { "type": "string" }, + "expires_at": { + "description": "ExpiresAt is the time the token expires. Zero value means no expiry.", + "type": "string" + }, "password": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 93bfcc07bdf04..064843d5ea89f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -12983,6 +12983,10 @@ "access_token": { "type": "string" }, + "expires_at": { + "description": "ExpiresAt is the time the token expires. Zero value means no expiry.", + "type": "string" + }, "password": { "type": "string" }, diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 61271500a22ef..220115d59af4f 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -271,6 +271,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/external-auth?mat ```json { "access_token": "string", + "expires_at": "string", "password": "string", "token_extra": {}, "type": "string", @@ -315,6 +316,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/gitauth?match=str ```json { "access_token": "string", + "expires_at": "string", "password": "string", "token_extra": {}, "type": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 4e4a49745c95d..0adf971b649f1 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -55,6 +55,7 @@ ```json { "access_token": "string", + "expires_at": "string", "password": "string", "token_extra": {}, "type": "string", @@ -68,6 +69,7 @@ | Name | Type | Required | Restrictions | Description | |----------------|--------|----------|--------------|------------------------------------------------------------------------------------------| | `access_token` | string | false | | | +| `expires_at` | string | false | | Expires at is the time the token expires. Zero value means no expiry. | | `password` | string | false | | | | `token_extra` | object | false | | | | `type` | string | false | | | diff --git a/docs/reference/cli/external-auth_access-token.md b/docs/reference/cli/external-auth_access-token.md index f7f8960b48bd9..3b3b2edf28d6b 100644 --- a/docs/reference/cli/external-auth_access-token.md +++ b/docs/reference/cli/external-auth_access-token.md @@ -29,6 +29,10 @@ fi - 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, including expiry.: + + $ coder external-auth access-token github --output json ``` ## Options @@ -41,6 +45,15 @@ fi Extract a field from the "extra" properties of the OAuth token. +### --output + +| | | +|---------|-------------------------| +| Type | text\|json | +| Default | text | + +Output format. Available formats: text, json. + ### --agent-token | | | From 8a0e4e80fcd7af67fdb59e6e7e947f817ac18221 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 30 Jun 2026 12:34:13 -0700 Subject: [PATCH 03/10] chore: update golden file for external-auth access-token --output flag --- .../coder_external-auth_access-token_--help.golden | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cli/testdata/coder_external-auth_access-token_--help.golden b/cli/testdata/coder_external-auth_access-token_--help.golden index ce11b0a8a77b8..f93690cd01635 100644 --- a/cli/testdata/coder_external-auth_access-token_--help.golden +++ b/cli/testdata/coder_external-auth_access-token_--help.golden @@ -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, including expiry.: + + $ coder external-auth access-token github --output json OPTIONS: --auth string, $CODER_AGENT_AUTH (default: token) @@ -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. From ed400edd0c2a71cb5610ca9bd9782cfb6c78c24b Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 6 Jul 2026 17:33:09 -0700 Subject: [PATCH 04/10] test(coderd): drop no-op second truncation in TestCreateExternalAuthResponse The test calls createExternalAuthResponse directly in-process, so there is no JSON round-trip to protect against, and dbtime.Now() already strips the monotonic clock reading via Round. The truncation only masked whether sub-second precision survives createExternalAuthResponse, which does not truncate. --- coderd/workspaceagents_internal_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/workspaceagents_internal_test.go b/coderd/workspaceagents_internal_test.go index b879bb9f8cff3..716e9795f0a59 100644 --- a/coderd/workspaceagents_internal_test.go +++ b/coderd/workspaceagents_internal_test.go @@ -986,12 +986,12 @@ func TestWatchAgentContainers(t *testing.T) { func TestCreateExternalAuthResponse(t *testing.T) { t.Parallel() - // Use a fixed future time, truncated to seconds to survive JSON round-trips. - expiry := dbtime.Now().Add(8 * time.Hour).UTC().Truncate(time.Second) + // 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().Truncate(time.Second), + require.Equal(t, want.UTC(), resp.ExpiresAt.UTC(), "ExpiresAt should match the expiry passed to createExternalAuthResponse") } From b7c245a45f3443d58d88faa8f76883fed6d10f59 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 6 Jul 2026 17:49:06 -0700 Subject: [PATCH 05/10] fix(cli): trim redundant clause from access-token JSON example description "Print the full token response as JSON" already implies expiry is included since ExpiresAt is a field on the response. --- cli/externalauth.go | 2 +- cli/testdata/coder_external-auth_access-token_--help.golden | 2 +- docs/reference/cli/external-auth_access-token.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/externalauth.go b/cli/externalauth.go index 970ec8eb248aa..5b10ca6f0fb07 100644 --- a/cli/externalauth.go +++ b/cli/externalauth.go @@ -55,7 +55,7 @@ fi Command: "coder external-auth access-token slack --extra \"authed_user.id\"", }, Example{ - Description: "Print the full token response as JSON, including expiry.", + Description: "Print the full token response as JSON.", Command: "coder external-auth access-token github --output json", }, ), diff --git a/cli/testdata/coder_external-auth_access-token_--help.golden b/cli/testdata/coder_external-auth_access-token_--help.golden index f93690cd01635..48665dd3b0703 100644 --- a/cli/testdata/coder_external-auth_access-token_--help.golden +++ b/cli/testdata/coder_external-auth_access-token_--help.golden @@ -24,7 +24,7 @@ USAGE: $ coder external-auth access-token slack --extra "authed_user.id" - - Print the full token response as JSON, including expiry.: + - Print the full token response as JSON.: $ coder external-auth access-token github --output json diff --git a/docs/reference/cli/external-auth_access-token.md b/docs/reference/cli/external-auth_access-token.md index 3b3b2edf28d6b..1422b0a8debc0 100644 --- a/docs/reference/cli/external-auth_access-token.md +++ b/docs/reference/cli/external-auth_access-token.md @@ -30,7 +30,7 @@ fi $ coder external-auth access-token slack --extra "authed_user.id" - - Print the full token response as JSON, including expiry.: + - Print the full token response as JSON.: $ coder external-auth access-token github --output json ``` From bf2f666eebc12629dfff26cded38a631c4bc4f02 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 6 Jul 2026 18:07:24 -0700 Subject: [PATCH 06/10] refactor(cli): consolidate duplicated extAuth.URL exit-code check The json and text output branches both checked extAuth.URL to decide whether to return cliui.ErrCanceled. Move that check after the output switch so it happens once regardless of output format. --- cli/externalauth.go | 35 ++++++++++++++--------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/cli/externalauth.go b/cli/externalauth.go index 5b10ca6f0fb07..9a143f60dc158 100644 --- a/cli/externalauth.go +++ b/cli/externalauth.go @@ -96,29 +96,20 @@ fi return xerrors.Errorf("get external auth token: %w", err) } - if outputFormat == "json" { + switch { + case outputFormat == "json": data, err := json.MarshalIndent(extAuth, "", " ") if err != nil { return xerrors.Errorf("marshal external auth response: %w", err) } - _, err = inv.Stdout.Write(data) - if err != nil { + if _, err := inv.Stdout.Write(data); err != nil { return err } - if extAuth.URL != "" { - return cliui.ErrCanceled - } - return nil - } - - if extAuth.URL != "" { - _, err = inv.Stdout.Write([]byte(extAuth.URL)) - if err != nil { + case extAuth.URL != "": + if _, err := inv.Stdout.Write([]byte(extAuth.URL)); err != nil { return err } - return cliui.ErrCanceled - } - if extra != "" { + case extra != "": if extAuth.TokenExtra == nil { return xerrors.Errorf("no extra properties found for token") } @@ -127,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 }, From 1c57280044b7c3f30ae8b96e468af6df0138b44f Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 6 Jul 2026 18:19:43 -0700 Subject: [PATCH 07/10] test(cli): table-drive JSONOutput subtests in TestExternalAuth JSONOutputWithExpiry and JSONOutputWithURL duplicated identical server/invocation/unmarshal scaffolding and only differed in the response payload and expected exit outcome. Consolidate into a table-driven JSONOutput test with per-case subtests. --- cli/externalauth_test.go | 71 ++++++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/cli/externalauth_test.go b/cli/externalauth_test.go index ab5e91581465e..bef55ec980286 100644 --- a/cli/externalauth_test.go +++ b/cli/externalauth_test.go @@ -83,41 +83,48 @@ func TestExternalAuth(t *testing.T) { clitest.Start(t, inv) stdout.ExpectMatch(ctx, "there") }) - t.Run("JSONOutputWithExpiry", func(t *testing.T) { + t.Run("JSONOutput", func(t *testing.T) { t.Parallel() expiry := time.Now().Add(8 * time.Hour).UTC().Truncate(time.Second) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{ - AccessToken: "bananas", - ExpiresAt: expiry, - }) - })) - 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 - clitest.StartWithWaiter(t, inv).RequireSuccess() - var resp agentsdk.ExternalAuthResponse - require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) - require.Equal(t, "bananas", resp.AccessToken) - require.Equal(t, expiry, resp.ExpiresAt.UTC().Truncate(time.Second)) - }) - t.Run("JSONOutputWithURL", 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/login", - }) - })) - 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 - clitest.StartWithWaiter(t, inv).RequireIs(cliui.ErrCanceled) + 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, "https://github.com/login", resp.URL) + 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()) + }) + } }) } From 77f713bc2f87108876d9d202988301f6bf513ecc Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 6 Jul 2026 18:33:12 -0700 Subject: [PATCH 08/10] test(coderd): table-drive provider mapping cases in TestCreateExternalAuthResponse GitHub, GitLab, BitbucketCloud, and BitbucketServer subtests were near-identical, differing only in provider type, token, and expected Username/Password pair. Consolidate into a single table-driven loop. WithExpiry, ZeroExpiry, WithTokenExtra, and InvalidExtraJSON test distinct behaviors and stay as standalone subtests. --- coderd/workspaceagents_internal_test.go | 103 +++++++++++------------- 1 file changed, 47 insertions(+), 56 deletions(-) diff --git a/coderd/workspaceagents_internal_test.go b/coderd/workspaceagents_internal_test.go index 716e9795f0a59..f6b09c614bd8d 100644 --- a/coderd/workspaceagents_internal_test.go +++ b/coderd/workspaceagents_internal_test.go @@ -1015,63 +1015,54 @@ func TestCreateExternalAuthResponse(t *testing.T) { // Each provider type maps the token into a different Username/Password pair. // All of them must also carry ExpiresAt through unchanged. - t.Run("GitHub", func(t *testing.T) { - t.Parallel() - - resp, err := createExternalAuthResponse( - codersdk.EnhancedExternalAuthProviderGitHub.String(), "ghtoken", - pqtype.NullRawMessage{}, expiry, - ) - require.NoError(t, err) - // GitHub tokens are placed in Username, Password is empty. - require.Equal(t, "ghtoken", resp.Username) - require.Empty(t, resp.Password) - require.Equal(t, "ghtoken", resp.AccessToken) - assertExpiry(t, resp, expiry) - }) - - t.Run("GitLab", func(t *testing.T) { - t.Parallel() - - resp, err := createExternalAuthResponse( - codersdk.EnhancedExternalAuthProviderGitLab.String(), "gltoken", - pqtype.NullRawMessage{}, expiry, - ) - require.NoError(t, err) - // GitLab uses oauth2/token as the credential pair. - require.Equal(t, "oauth2", resp.Username) - require.Equal(t, "gltoken", resp.Password) - require.Equal(t, "gltoken", resp.AccessToken) - assertExpiry(t, resp, expiry) - }) - - t.Run("BitbucketCloud", func(t *testing.T) { - t.Parallel() - - resp, err := createExternalAuthResponse( - codersdk.EnhancedExternalAuthProviderBitBucketCloud.String(), "bbtoken", - pqtype.NullRawMessage{}, expiry, - ) - require.NoError(t, err) - require.Equal(t, "x-token-auth", resp.Username) - require.Equal(t, "bbtoken", resp.Password) - require.Equal(t, "bbtoken", resp.AccessToken) - assertExpiry(t, resp, expiry) - }) - - t.Run("BitbucketServer", func(t *testing.T) { - t.Parallel() + 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( - codersdk.EnhancedExternalAuthProviderBitBucketServer.String(), "bbtoken", - pqtype.NullRawMessage{}, expiry, - ) - require.NoError(t, err) - require.Equal(t, "x-token-auth", resp.Username) - require.Equal(t, "bbtoken", resp.Password) - require.Equal(t, "bbtoken", resp.AccessToken) - assertExpiry(t, resp, expiry) - }) + 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() From cdbe7de2cbb7f373f7c507677583107691f9009b Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 6 Jul 2026 18:39:05 -0700 Subject: [PATCH 09/10] docs(codersdk/agentsdk): clarify ExpiresAt UTC normalization with an example ExternalAuthResponse is public SDK surface, so callers outside this repo may rely on the doc comment rather than the implementation. --- codersdk/agentsdk/agentsdk.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 0be1cd031a650..c199b1c873b88 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -724,7 +724,8 @@ type ExternalAuthResponse struct { TokenExtra map[string]interface{} `json:"token_extra"` URL string `json:"url"` Type string `json:"type"` - // ExpiresAt is the time the token expires. Zero value means no expiry. + // ExpiresAt is the time the token expires, normalized to UTC (for + // example, "2024-06-01T15:04:05Z"). Zero value means no expiry. ExpiresAt time.Time `json:"expires_at"` // Deprecated: Only supported on `/workspaceagents/me/gitauth` From fedb890453b6f002fbdd13f26a1242aa27f5ba5e Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 6 Jul 2026 19:36:35 -0700 Subject: [PATCH 10/10] fix: regenerate apidoc for ExpiresAt doc comment update The Makefile's coderd/apidoc/.gen prerequisites only glob codersdk/*.go, not codersdk/agentsdk/*.go, so the earlier doc comment change in codersdk/agentsdk/agentsdk.go did not trigger a local regeneration and CI's gen check caught the stale swagger output. --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- docs/reference/api/schemas.md | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0ced8614c7192..fa655ebb6b1f8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -14632,7 +14632,7 @@ const docTemplate = `{ "type": "string" }, "expires_at": { - "description": "ExpiresAt is the time the token expires. Zero value means no expiry.", + "description": "ExpiresAt is the time the token expires, normalized to UTC (for\nexample, \"2024-06-01T15:04:05Z\"). Zero value means no expiry.", "type": "string" }, "password": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index cb399ac2b23fd..05ea8fb5e81f8 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -12984,7 +12984,7 @@ "type": "string" }, "expires_at": { - "description": "ExpiresAt is the time the token expires. Zero value means no expiry.", + "description": "ExpiresAt is the time the token expires, normalized to UTC (for\nexample, \"2024-06-01T15:04:05Z\"). Zero value means no expiry.", "type": "string" }, "password": { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 6e5d2ead67751..063b57bb1f062 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -66,15 +66,15 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|----------------|--------|----------|--------------|------------------------------------------------------------------------------------------| -| `access_token` | string | false | | | -| `expires_at` | string | false | | Expires at is the time the token expires. Zero value means no expiry. | -| `password` | string | false | | | -| `token_extra` | object | false | | | -| `type` | string | false | | | -| `url` | string | false | | | -| `username` | string | false | | Deprecated: Only supported on `/workspaceagents/me/gitauth` for backwards compatibility. | +| Name | Type | Required | Restrictions | Description | +|----------------|--------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------| +| `access_token` | string | false | | | +| `expires_at` | string | false | | Expires at is the time the token expires, normalized to UTC (for example, "2024-06-01T15:04:05Z"). Zero value means no expiry. | +| `password` | string | false | | | +| `token_extra` | object | false | | | +| `type` | string | false | | | +| `url` | string | false | | | +| `username` | string | false | | Deprecated: Only supported on `/workspaceagents/me/gitauth` for backwards compatibility. | ## agentsdk.GitSSHKey