diff --git a/otdfctl/pkg/auth/errors.go b/otdfctl/pkg/auth/errors.go index c26dea08c2..b074779795 100644 --- a/otdfctl/pkg/auth/errors.go +++ b/otdfctl/pkg/auth/errors.go @@ -10,4 +10,6 @@ var ( ErrUnauthenticated = errors.New("not logged in") ErrParsingAccessToken = errors.New("failed to parse access token") ErrProfileCredentialsNotFound = errors.New("profile missing credentials") + ErrNoRefreshToken = errors.New("no refresh token available") + ErrRefreshFailed = errors.New("token refresh failed") ) diff --git a/otdfctl/pkg/auth/refresh.go b/otdfctl/pkg/auth/refresh.go new file mode 100644 index 0000000000..8965dbcb4a --- /dev/null +++ b/otdfctl/pkg/auth/refresh.go @@ -0,0 +1,145 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/opentdf/platform/otdfctl/pkg/profiles" + "github.com/opentdf/platform/otdfctl/pkg/utils" + "golang.org/x/oauth2" +) + +const ( + DefaultPublicClientID = "cli-client" + // expiryBuffer is added to the current time to account for token expiry occurring during + // subprocess startup and network latency between the expiry check and the actual API call. + expiryBuffer = 30 * time.Second +) + +// tokenEndpointResolver looks up the OAuth2 token endpoint for a given +// platform endpoint. Production code uses getTokenEndpoint; tests inject +// a stub to avoid real gRPC calls. +type tokenEndpointResolver func(endpoint string, tlsNoVerify bool) (string, error) + +// RefreshAccessToken refreshes the access token using the stored refresh token +// and updates the profile with the new tokens. +func RefreshAccessToken(ctx context.Context, profile *profiles.OtdfctlProfileStore) error { + return refreshAccessToken(ctx, profile, getTokenEndpoint) +} + +func refreshAccessToken(ctx context.Context, profile *profiles.OtdfctlProfileStore, resolveEndpoint tokenEndpointResolver) error { + if profile == nil { + return errors.New("profile is required") + } + + creds := profile.GetAuthCredentials() + + if creds.AuthType != profiles.AuthTypeAccessToken { + return fmt.Errorf("%w: auth type is %s, not access-token", ErrInvalidAuthType, creds.AuthType) + } + + if creds.AccessToken.RefreshToken == "" { + return ErrNoRefreshToken + } + + endpoint := profile.GetEndpoint() + tlsNoVerify := profile.GetTLSNoVerify() + + normalized, err := utils.NormalizeEndpoint(endpoint) + if err != nil { + return fmt.Errorf("failed to normalize endpoint: %w", err) + } + + tokenEndpoint, err := resolveEndpoint(normalized.String(), tlsNoVerify) + if err != nil { + return fmt.Errorf("failed to get token endpoint: %w", err) + } + + clientID := creds.AccessToken.ClientID + if clientID == "" { + clientID = DefaultPublicClientID + } + + oauth2Config := &oauth2.Config{ + ClientID: clientID, + Endpoint: oauth2.Endpoint{ + TokenURL: tokenEndpoint, + }, + } + + oldToken := &oauth2.Token{ + RefreshToken: creds.AccessToken.RefreshToken, + } + + if tlsNoVerify { + httpClient := utils.NewHTTPClient(tlsNoVerify) + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) + } + + tokenSource := oauth2Config.TokenSource(ctx, oldToken) + newToken, err := tokenSource.Token() + if err != nil { + return fmt.Errorf("%w: %w", ErrRefreshFailed, err) + } + + slog.Debug("successfully refreshed access token") + + expiration := newToken.Expiry.Unix() + if newToken.Expiry.IsZero() { + expiration = time.Now().Add(time.Hour).Unix() + slog.Warn("token response missing expires_in, assuming 1 hour") + } + + newCreds := profiles.AuthCredentials{ + AuthType: profiles.AuthTypeAccessToken, + AccessToken: profiles.AuthCredentialsAccessToken{ + ClientID: clientID, + AccessToken: newToken.AccessToken, + RefreshToken: newToken.RefreshToken, + Expiration: expiration, + }, + } + + if err := profile.SetAuthCredentials(newCreds); err != nil { + return fmt.Errorf("failed to save refreshed credentials: %w", err) + } + + slog.Info("access token refreshed and saved") + return nil +} + +// IsTokenExpired checks if the access token in the profile is expired. +// Returns false for non-access-token auth types since refresh only applies there. +func IsTokenExpired(profile *profiles.OtdfctlProfileStore) bool { + if profile == nil { + return true + } + creds := profile.GetAuthCredentials() + if creds.AuthType != profiles.AuthTypeAccessToken { + return false + } + expiry := time.Unix(creds.AccessToken.Expiration, 0) + // We are checking if the current time plus the buffer is after the true token expiry time. + // If it is, we refresh the token. The purpose of the buffer is to avoid expiry between calls. + return time.Now().Add(expiryBuffer).After(expiry) +} + +// HasRefreshToken checks if the profile has a refresh token. +func HasRefreshToken(profile *profiles.OtdfctlProfileStore) bool { + if profile == nil { + return false + } + creds := profile.GetAuthCredentials() + return creds.AuthType == profiles.AuthTypeAccessToken && creds.AccessToken.RefreshToken != "" +} + +func getTokenEndpoint(endpoint string, tlsNoVerify bool) (string, error) { + pc, err := getPlatformConfiguration(endpoint, tlsNoVerify) + if err != nil { + return "", fmt.Errorf("failed to get platform configuration: %w", err) + } + return pc.tokenEndpoint, nil +} diff --git a/otdfctl/pkg/auth/refresh_test.go b/otdfctl/pkg/auth/refresh_test.go new file mode 100644 index 0000000000..b7e4c9c0af --- /dev/null +++ b/otdfctl/pkg/auth/refresh_test.go @@ -0,0 +1,315 @@ +package auth + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/opentdf/platform/otdfctl/pkg/profiles" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestProfile(t *testing.T, authType string, accessToken, refreshToken string, expiration int64) *profiles.OtdfctlProfileStore { + t.Helper() + cfg := &profiles.ProfileConfig{ + Name: "test", + Endpoint: "https://example.com", + TLSNoVerify: false, + } + store, err := profiles.NewOtdfctlProfileStore(profiles.ProfileDriverMemory, cfg, false) + require.NoError(t, err) + err = store.SetAuthCredentials(profiles.AuthCredentials{ + AuthType: authType, + AccessToken: profiles.AuthCredentialsAccessToken{ + ClientID: "cli-client", + AccessToken: accessToken, + RefreshToken: refreshToken, + Expiration: expiration, + }, + }) + require.NoError(t, err) + return store +} + +func TestIsTokenExpired(t *testing.T) { + tests := []struct { + name string + authType string + exp int64 + want bool + }{ + { + name: "expired token", + authType: profiles.AuthTypeAccessToken, + exp: time.Now().Add(-time.Hour).Unix(), + want: true, + }, + { + name: "valid token", + authType: profiles.AuthTypeAccessToken, + exp: time.Now().Add(time.Hour).Unix(), + want: false, + }, + { + name: "token within expiry buffer", + authType: profiles.AuthTypeAccessToken, + exp: time.Now().Add(10 * time.Second).Unix(), + want: true, + }, + { + name: "non-access-token auth type", + authType: profiles.AuthTypeClientCredentials, + exp: time.Now().Add(-time.Hour).Unix(), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + profile := newTestProfile(t, tt.authType, "tok", "refresh", tt.exp) + got := IsTokenExpired(profile) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestHasRefreshToken(t *testing.T) { + tests := []struct { + name string + authType string + refreshToken string + want bool + }{ + { + name: "has refresh token", + authType: profiles.AuthTypeAccessToken, + refreshToken: "refresh-tok", + want: true, + }, + { + name: "no refresh token", + authType: profiles.AuthTypeAccessToken, + refreshToken: "", + want: false, + }, + { + name: "wrong auth type", + authType: profiles.AuthTypeClientCredentials, + refreshToken: "refresh-tok", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + profile := newTestProfile(t, tt.authType, "tok", tt.refreshToken, time.Now().Add(time.Hour).Unix()) + got := HasRefreshToken(profile) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRefreshAccessTokenWrongAuthType(t *testing.T) { + profile := newTestProfile(t, profiles.AuthTypeClientCredentials, "tok", "refresh", time.Now().Add(time.Hour).Unix()) + err := RefreshAccessToken(t.Context(), profile) + require.ErrorIs(t, err, ErrInvalidAuthType) +} + +func TestRefreshAccessTokenNoRefreshToken(t *testing.T) { + profile := newTestProfile(t, profiles.AuthTypeAccessToken, "tok", "", time.Now().Add(-time.Hour).Unix()) + err := RefreshAccessToken(t.Context(), profile) + require.ErrorIs(t, err, ErrNoRefreshToken) +} + +func TestRefreshAccessTokenSuccess(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(map[string]any{ + "access_token": "new-access-token", + "refresh_token": "new-refresh-token", + "token_type": "Bearer", + "expires_in": 3600, + }) + assert.NoError(t, err) + })) + defer tokenServer.Close() + + resolver := func(string, bool) (string, error) { + return tokenServer.URL, nil + } + + profile := newTestProfile(t, profiles.AuthTypeAccessToken, "old-token", "old-refresh", time.Now().Add(-time.Hour).Unix()) + + err := refreshAccessToken(t.Context(), profile, resolver) + require.NoError(t, err) + + creds := profile.GetAuthCredentials() + assert.Equal(t, "new-access-token", creds.AccessToken.AccessToken) + assert.Equal(t, "new-refresh-token", creds.AccessToken.RefreshToken) + assert.Greater(t, creds.AccessToken.Expiration, time.Now().Unix(), + "expiration should be updated to a future timestamp") +} + +func TestRefreshAccessTokenZeroExpiry(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(map[string]any{ + "access_token": "new-token", + "refresh_token": "new-refresh", + "token_type": "Bearer", + }) + assert.NoError(t, err) + })) + defer tokenServer.Close() + + resolver := func(string, bool) (string, error) { + return tokenServer.URL, nil + } + + profile := newTestProfile(t, profiles.AuthTypeAccessToken, "old", "refresh", time.Now().Add(-time.Hour).Unix()) + err := refreshAccessToken(t.Context(), profile, resolver) + require.NoError(t, err) + + creds := profile.GetAuthCredentials() + assert.Greater(t, creds.AccessToken.Expiration, time.Now().Unix(), + "zero expiry should default to ~1 hour from now") +} + +func TestRefreshAccessTokenEndpointError(t *testing.T) { + resolver := func(string, bool) (string, error) { + return "", errors.New("gRPC connection failed") + } + + profile := newTestProfile(t, profiles.AuthTypeAccessToken, "tok", "refresh", time.Now().Add(-time.Hour).Unix()) + err := refreshAccessToken(t.Context(), profile, resolver) + require.ErrorContains(t, err, "failed to get token endpoint") +} + +func TestRefreshAccessTokenRefreshFails(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + err := json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"}) + assert.NoError(t, err) + })) + defer tokenServer.Close() + + resolver := func(string, bool) (string, error) { + return tokenServer.URL, nil + } + + profile := newTestProfile(t, profiles.AuthTypeAccessToken, "tok", "refresh", time.Now().Add(-time.Hour).Unix()) + err := refreshAccessToken(t.Context(), profile, resolver) + require.Error(t, err) + require.ErrorIs(t, err, ErrRefreshFailed) +} + +func TestRefreshAccessTokenEmptyClientID(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(map[string]any{ + "access_token": "new-token", + "refresh_token": "new-refresh", + "token_type": "Bearer", + "expires_in": 3600, + }) + assert.NoError(t, err) + })) + defer tokenServer.Close() + + resolver := func(string, bool) (string, error) { + return tokenServer.URL, nil + } + + cfg := &profiles.ProfileConfig{ + Name: "test", + Endpoint: "https://example.com", + } + profile, err := profiles.NewOtdfctlProfileStore(profiles.ProfileDriverMemory, cfg, false) + require.NoError(t, err) + err = profile.SetAuthCredentials(profiles.AuthCredentials{ + AuthType: profiles.AuthTypeAccessToken, + AccessToken: profiles.AuthCredentialsAccessToken{ + ClientID: "", + AccessToken: "old-token", + RefreshToken: "old-refresh", + Expiration: time.Now().Add(-time.Hour).Unix(), + }, + }) + require.NoError(t, err) + + err = refreshAccessToken(t.Context(), profile, resolver) + require.NoError(t, err) + + creds := profile.GetAuthCredentials() + assert.Equal(t, DefaultPublicClientID, creds.AccessToken.ClientID) +} + +func TestRefreshAccessTokenTLSNoVerify(t *testing.T) { + tokenServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(map[string]any{ + "access_token": "tls-token", + "refresh_token": "tls-refresh", + "token_type": "Bearer", + "expires_in": 3600, + }) + assert.NoError(t, err) + })) + defer tokenServer.Close() + + resolver := func(string, bool) (string, error) { + return tokenServer.URL, nil + } + + cfg := &profiles.ProfileConfig{ + Name: "test", + Endpoint: "https://example.com", + TLSNoVerify: true, + } + profile, err := profiles.NewOtdfctlProfileStore(profiles.ProfileDriverMemory, cfg, false) + require.NoError(t, err) + err = profile.SetAuthCredentials(profiles.AuthCredentials{ + AuthType: profiles.AuthTypeAccessToken, + AccessToken: profiles.AuthCredentialsAccessToken{ + ClientID: "cli-client", + AccessToken: "old-token", + RefreshToken: "old-refresh", + Expiration: time.Now().Add(-time.Hour).Unix(), + }, + }) + require.NoError(t, err) + + err = refreshAccessToken(t.Context(), profile, resolver) + require.NoError(t, err) + + creds := profile.GetAuthCredentials() + assert.Equal(t, "tls-token", creds.AccessToken.AccessToken) +} + +func TestGetTokenEndpointBadEndpoint(t *testing.T) { + _, err := getTokenEndpoint("https://localhost:1", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get platform configuration") +} + +func TestGetTokenEndpointEmptyEndpoint(t *testing.T) { + _, err := getTokenEndpoint("", false) + require.Error(t, err) +} + +func TestRefreshAccessTokenNilProfile(t *testing.T) { + err := RefreshAccessToken(t.Context(), nil) + require.Error(t, err) +} + +func TestIsTokenExpiredNilProfile(t *testing.T) { + assert.True(t, IsTokenExpired(nil)) +} + +func TestHasRefreshTokenNilProfile(t *testing.T) { + assert.False(t, HasRefreshToken(nil)) +} diff --git a/otdfctl/pkg/man/man.go b/otdfctl/pkg/man/man.go index c6d36c53b3..0039c4b216 100644 --- a/otdfctl/pkg/man/man.go +++ b/otdfctl/pkg/man/man.go @@ -236,16 +236,18 @@ func ProcessDoc(doc string) (*Doc, error) { long := "# " + matter.Title + "\n\n" + strings.TrimSpace(string(rest)) var args cobra.PositionalArgs - if len(c.Args) > 0 { + switch { + case len(c.Args) > 0 && len(c.ArbitraryArgs) > 0: + args = cobra.MinimumNArgs(len(c.Args)) + case len(c.Args) > 0: args = cobra.ExactArgs(len(c.Args)) - } - if len(c.ArbitraryArgs) > 0 { + case len(c.ArbitraryArgs) > 0: args = cobra.ArbitraryArgs } d := Doc{ cobra.Command{ - Use: c.Name, + Use: buildUseString(c.Name, c.Args, c.ArbitraryArgs), Args: args, Hidden: c.Hidden, Aliases: c.Aliases, @@ -258,3 +260,15 @@ func ProcessDoc(doc string) (*Doc, error) { return &d, nil } + +func buildUseString(name string, args, arbitraryArgs []string) string { + parts := make([]string, 0, 1+len(args)+len(arbitraryArgs)) + parts = append(parts, name) + for _, a := range args { + parts = append(parts, "<"+a+">") + } + for _, a := range arbitraryArgs { + parts = append(parts, "["+a+"]") + } + return strings.Join(parts, " ") +} diff --git a/otdfctl/pkg/man/man_test.go b/otdfctl/pkg/man/man_test.go new file mode 100644 index 0000000000..023b04fbe0 --- /dev/null +++ b/otdfctl/pkg/man/man_test.go @@ -0,0 +1,135 @@ +package man + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProcessDocNoArgs(t *testing.T) { + doc, err := ProcessDoc(`--- +title: List namespaces +command: + name: list +--- + +List all namespaces. +`) + require.NoError(t, err) + assert.Equal(t, "list", doc.Use) +} + +func TestProcessDocWithArgs(t *testing.T) { + doc, err := ProcessDoc(`--- +title: Get a resource +command: + name: get + arguments: + - resource-id +--- + +Get a resource by ID. +`) + require.NoError(t, err) + assert.Equal(t, "get ", doc.Use) +} + +func TestProcessDocWithArbitraryArgs(t *testing.T) { + doc, err := ProcessDoc(`--- +title: Do something +command: + name: do + arbitraryArgs: + - optional-arg +--- + +Do something optionally. +`) + require.NoError(t, err) + assert.Equal(t, "do [optional-arg]", doc.Use) +} + +func TestProcessDocWithBothArgTypes(t *testing.T) { + doc, err := ProcessDoc(`--- +title: Authenticate with client credentials +command: + name: client-credentials + arguments: + - client-id + arbitraryArgs: + - client-secret +--- + +Authenticate via client credentials flow. +`) + require.NoError(t, err) + assert.Equal(t, "client-credentials [client-secret]", doc.Use) +} + +func TestProcessDocWithBothArgTypesValidator(t *testing.T) { + doc, err := ProcessDoc(`--- +title: Authenticate with client credentials +command: + name: client-credentials + arguments: + - client-id + arbitraryArgs: + - client-secret +--- + +Authenticate via client credentials flow. +`) + require.NoError(t, err) + require.NoError(t, doc.Args(&doc.Command, []string{"id"})) + require.NoError(t, doc.Args(&doc.Command, []string{"id", "secret"})) + require.Error(t, doc.Args(&doc.Command, []string{})) +} + +func TestBuildUseString(t *testing.T) { + tests := []struct { + name string + cmdName string + args []string + arbitraryArgs []string + want string + }{ + { + name: "name only", + cmdName: "list", + want: "list", + }, + { + name: "with required args", + cmdName: "get", + args: []string{"id"}, + want: "get ", + }, + { + name: "with optional args", + cmdName: "run", + arbitraryArgs: []string{"extra"}, + want: "run [extra]", + }, + { + name: "with both", + cmdName: "auth", + args: []string{"client-id"}, + arbitraryArgs: []string{"client-secret"}, + want: "auth [client-secret]", + }, + { + name: "multiple required", + cmdName: "copy", + args: []string{"src", "dst"}, + want: "copy ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildUseString(tt.cmdName, tt.args, tt.arbitraryArgs) + assert.Equal(t, tt.want, got) + }) + } +}