diff --git a/cli/externalauth.go b/cli/externalauth.go index d235e7b0d75..9a143f60dc1 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.", + 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,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") } @@ -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 }, diff --git a/cli/externalauth_test.go b/cli/externalauth_test.go index 614505f309f..bef55ec9802 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,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()) + }) + } + }) } diff --git a/cli/testdata/coder_external-auth_access-token_--help.golden b/cli/testdata/coder_external-auth_access-token_--help.golden index ce11b0a8a77..48665dd3b07 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.: + + $ 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. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f35834e7f0c..576ff8aad1d 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, normalized to UTC (for\nexample, \"2024-06-01T15:04:05Z\"). Zero value means no expiry.", + "type": "string" + }, "password": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index feb2b31641c..2993ba71292 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, normalized to UTC (for\nexample, \"2024-06-01T15:04:05Z\"). Zero value means no expiry.", + "type": "string" + }, "password": { "type": "string" }, diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 915cd2ac909..3a7fddfb61e 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 f7f9ff59542..f6b09c614bd 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,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) + }) + + 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 1e52d1e35cf..65c7eb1dbdf 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 76ca9394537..c199b1c873b 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -724,6 +724,9 @@ 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, 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` // for backwards compatibility. diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 61271500a22..220115d59af 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 1653a311636..4000260fdba 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", @@ -65,14 +66,15 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|----------------|--------|----------|--------------|------------------------------------------------------------------------------------------| -| `access_token` | string | false | | | -| `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 diff --git a/docs/reference/cli/external-auth_access-token.md b/docs/reference/cli/external-auth_access-token.md index f7f8960b48b..1422b0a8deb 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.: + + $ 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 | | |