From bc0c2e0e1a5e6d0e10b0d5eccbaca039e1910647 Mon Sep 17 00:00:00 2001 From: Elizabeth Healy Date: Thu, 9 Jul 2026 11:11:45 -0400 Subject: [PATCH] extending otdfctl auth --- otdfctl/README.md | 57 ++++++++++++ otdfctl/pkg/auth/auth.go | 55 ++++-------- otdfctl/pkg/auth/provider.go | 85 ++++++++++++++++++ otdfctl/pkg/auth/provider_test.go | 106 ++++++++++++++++++++++ otdfctl/pkg/profiles/extensions.go | 79 +++++++++++++++++ otdfctl/pkg/profiles/extensions_test.go | 113 ++++++++++++++++++++++++ otdfctl/pkg/profiles/profileConfig.go | 4 + 7 files changed, 463 insertions(+), 36 deletions(-) create mode 100644 otdfctl/pkg/auth/provider.go create mode 100644 otdfctl/pkg/auth/provider_test.go create mode 100644 otdfctl/pkg/profiles/extensions.go create mode 100644 otdfctl/pkg/profiles/extensions_test.go diff --git a/otdfctl/README.md b/otdfctl/README.md index 15e3c0d592..89a9f21888 100644 --- a/otdfctl/README.md +++ b/otdfctl/README.md @@ -38,6 +38,63 @@ The output format (currently `styled` or `json`) is stored with each profile (vi 2. Run the handler which is located in `pkg/handlers` and pass the values as arguments 3. Handle any errors and return the result in a lite TUI format +### Extending otdfctl + +Projects that build a CLI on top of otdfctl (via `cmd.RootCmd.AddCommand(...)` or +`cmd.MountRoot`) can extend profiles and authentication without forking. + +#### Storing custom data on a profile + +Each profile has a namespaced extension store. Persist your own typed data with the generic +`profiles.GetExtension` / `profiles.SetExtension` helpers — otdfctl stores it as opaque JSON +and never interprets it: + +```go +type AcmeCreds struct { + APIKey string `json:"apiKey"` + Region string `json:"region"` +} + +// write +_ = profiles.SetExtension(store, "acme", AcmeCreds{APIKey: "abc", Region: "us"}) + +// read: absent -> (zero, false, nil) +creds, ok, err := profiles.GetExtension[AcmeCreds](store, "acme") +``` + +Different extensions may use different types; each owns its own name. + +#### Registering a custom auth process + +Authentication is pluggable. Implement `auth.Provider` and register it (typically from an +`init()`) to teach otdfctl a new auth type. The provider reads its credentials from the profile +— usually via the extension store above — and returns the matching `sdk.Option`: + +```go +const acmeAuthType = "acme" + +type acmeProvider struct{} + +func (acmeProvider) SDKAuthOption(p *profiles.OtdfctlProfileStore) (sdk.Option, error) { + creds, _, err := profiles.GetExtension[AcmeCreds](p, "acme") + if err != nil { + return nil, err + } + // Build an oauth2.TokenSource from creds, then wrap it: + return sdk.WithOAuthAccessTokenSource(tokenSourceFrom(creds)), nil +} + +func (acmeProvider) Validate(ctx context.Context, p *profiles.OtdfctlProfileStore) error { /* ... */ } +func (acmeProvider) GetToken(ctx context.Context, p *profiles.OtdfctlProfileStore) (*oauth2.Token, error) { /* ... */ } + +func init() { + auth.RegisterProvider(acmeAuthType, acmeProvider{}) +} +``` + +Set the profile's auth type to your custom string (via `store.SetAuthCredentials`) so otdfctl +dispatches to your provider when building the SDK client. + ### TUI > [!CAUTION] diff --git a/otdfctl/pkg/auth/auth.go b/otdfctl/pkg/auth/auth.go index bd505d88ab..fa811d8cfe 100644 --- a/otdfctl/pkg/auth/auth.go +++ b/otdfctl/pkg/auth/auth.go @@ -163,53 +163,36 @@ func ParseClaimsJWT(accessToken string) (JWTClaims, error) { return c, nil } +// GetSDKAuthOptionFromProfile dispatches to the provider registered for the profile's auth type. func GetSDKAuthOptionFromProfile(profile *profiles.OtdfctlProfileStore) (sdk.Option, error) { - c := profile.GetAuthCredentials() - - switch c.AuthType { - case profiles.AuthTypeClientCredentials: - return sdk.WithClientCredentials(c.ClientID, c.ClientSecret, NormalizeScopes(c.Scopes)), nil - case profiles.AuthTypeAccessToken: - tokenSource := oauth2.StaticTokenSource(buildToken(&c)) - return sdk.WithOAuthAccessTokenSource(tokenSource), nil - default: - return nil, ErrInvalidAuthType + provider, err := lookupProvider(profile.GetAuthCredentials().AuthType) + if err != nil { + return nil, err } + return provider.SDKAuthOption(profile) } +// ValidateProfileAuthCredentials dispatches to the provider registered for the profile's auth +// type. An unset auth type returns ErrProfileCredentialsNotFound. func ValidateProfileAuthCredentials(ctx context.Context, profile *profiles.OtdfctlProfileStore) error { - c := profile.GetAuthCredentials() - - switch c.AuthType { - case "": + authType := profile.GetAuthCredentials().AuthType + if authType == "" { return ErrProfileCredentialsNotFound - case profiles.AuthTypeClientCredentials: - _, err := GetTokenWithClientCreds(ctx, profile.GetEndpoint(), c.ClientID, c.ClientSecret, profile.GetTLSNoVerify(), c.Scopes) - if err != nil { - return err - } - return nil - case profiles.AuthTypeAccessToken: - if !buildToken(&c).Valid() { - return ErrAccessTokenExpired - } - default: - return ErrInvalidAuthType } - return nil + provider, err := lookupProvider(authType) + if err != nil { + return err + } + return provider.Validate(ctx, profile) } +// GetTokenWithProfile dispatches to the provider registered for the profile's auth type. func GetTokenWithProfile(ctx context.Context, profile *profiles.OtdfctlProfileStore) (*oauth2.Token, error) { - c := profile.GetAuthCredentials() - - switch c.AuthType { - case profiles.AuthTypeClientCredentials: - return GetTokenWithClientCreds(ctx, profile.GetEndpoint(), c.ClientID, c.ClientSecret, profile.GetTLSNoVerify(), c.Scopes) - case profiles.AuthTypeAccessToken: - return buildToken(&c), nil - default: - return nil, ErrInvalidAuthType + provider, err := lookupProvider(profile.GetAuthCredentials().AuthType) + if err != nil { + return nil, err } + return provider.GetToken(ctx, profile) } // Uses the OAuth2 client credentials flow to obtain a token. diff --git a/otdfctl/pkg/auth/provider.go b/otdfctl/pkg/auth/provider.go new file mode 100644 index 0000000000..842e3611f7 --- /dev/null +++ b/otdfctl/pkg/auth/provider.go @@ -0,0 +1,85 @@ +package auth + +import ( + "context" + + "github.com/opentdf/platform/otdfctl/pkg/profiles" + "github.com/opentdf/platform/sdk" + "golang.org/x/oauth2" +) + +// Provider builds SDK authentication for a single auth type (keyed by +// AuthCredentials.AuthType). Extending projects implement it and register it via +// RegisterProvider to add a custom authentication process. +type Provider interface { + // SDKAuthOption returns the sdk.Option used to authenticate the SDK client. + SDKAuthOption(profile *profiles.OtdfctlProfileStore) (sdk.Option, error) + // Validate reports whether the profile's stored credentials are present and usable. + Validate(ctx context.Context, profile *profiles.OtdfctlProfileStore) error + // GetToken returns an OAuth2 token for the profile. + GetToken(ctx context.Context, profile *profiles.OtdfctlProfileStore) (*oauth2.Token, error) +} + +// authProviders maps an auth type to its provider. Registration is init-time and +// single-threaded, so no locking is required. +var authProviders = map[string]Provider{} + +// RegisterProvider registers (or replaces) the provider for an auth type. +// Extending projects call this from init(). +func RegisterProvider(authType string, provider Provider) { + authProviders[authType] = provider +} + +// lookupProvider returns the provider for authType, or ErrInvalidAuthType if none. +func lookupProvider(authType string) (Provider, error) { + provider, ok := authProviders[authType] + if !ok { + return nil, ErrInvalidAuthType + } + return provider, nil +} + +func init() { + RegisterProvider(profiles.AuthTypeClientCredentials, clientCredentialsProvider{}) + RegisterProvider(profiles.AuthTypeAccessToken, accessTokenProvider{}) +} + +// clientCredentialsProvider implements the built-in OAuth2 client-credentials flow. +type clientCredentialsProvider struct{} + +func (clientCredentialsProvider) SDKAuthOption(profile *profiles.OtdfctlProfileStore) (sdk.Option, error) { + c := profile.GetAuthCredentials() + return sdk.WithClientCredentials(c.ClientID, c.ClientSecret, NormalizeScopes(c.Scopes)), nil +} + +func (clientCredentialsProvider) Validate(ctx context.Context, profile *profiles.OtdfctlProfileStore) error { + c := profile.GetAuthCredentials() + _, err := GetTokenWithClientCreds(ctx, profile.GetEndpoint(), c.ClientID, c.ClientSecret, profile.GetTLSNoVerify(), c.Scopes) + return err +} + +func (clientCredentialsProvider) GetToken(ctx context.Context, profile *profiles.OtdfctlProfileStore) (*oauth2.Token, error) { + c := profile.GetAuthCredentials() + return GetTokenWithClientCreds(ctx, profile.GetEndpoint(), c.ClientID, c.ClientSecret, profile.GetTLSNoVerify(), c.Scopes) +} + +// accessTokenProvider implements the built-in static access-token flow. +type accessTokenProvider struct{} + +func (accessTokenProvider) SDKAuthOption(profile *profiles.OtdfctlProfileStore) (sdk.Option, error) { + c := profile.GetAuthCredentials() + return sdk.WithOAuthAccessTokenSource(oauth2.StaticTokenSource(buildToken(&c))), nil +} + +func (accessTokenProvider) Validate(_ context.Context, profile *profiles.OtdfctlProfileStore) error { + c := profile.GetAuthCredentials() + if !buildToken(&c).Valid() { + return ErrAccessTokenExpired + } + return nil +} + +func (accessTokenProvider) GetToken(_ context.Context, profile *profiles.OtdfctlProfileStore) (*oauth2.Token, error) { + c := profile.GetAuthCredentials() + return buildToken(&c), nil +} diff --git a/otdfctl/pkg/auth/provider_test.go b/otdfctl/pkg/auth/provider_test.go new file mode 100644 index 0000000000..0ff6f4254a --- /dev/null +++ b/otdfctl/pkg/auth/provider_test.go @@ -0,0 +1,106 @@ +package auth + +import ( + "context" + "testing" + "time" + + "github.com/opentdf/platform/otdfctl/pkg/profiles" + "github.com/opentdf/platform/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +type fakeProvider struct { + sdkCalled bool + validateCalled bool + getTokenCalled bool + token *oauth2.Token +} + +func (f *fakeProvider) SDKAuthOption(_ *profiles.OtdfctlProfileStore) (sdk.Option, error) { + f.sdkCalled = true + return sdk.WithInsecurePlaintextConn(), nil +} + +func (f *fakeProvider) Validate(_ context.Context, _ *profiles.OtdfctlProfileStore) error { + f.validateCalled = true + return nil +} + +func (f *fakeProvider) GetToken(_ context.Context, _ *profiles.OtdfctlProfileStore) (*oauth2.Token, error) { + f.getTokenCalled = true + return f.token, nil +} + +func newProfileWithAuthCreds(t *testing.T, creds profiles.AuthCredentials) *profiles.OtdfctlProfileStore { + t.Helper() + store, err := profiles.NewOtdfctlProfileStore(profiles.ProfileDriverMemory, &profiles.ProfileConfig{ + Name: "test", + Endpoint: "http://localhost:8080", + }, true) + require.NoError(t, err) + require.NoError(t, store.SetAuthCredentials(creds)) + return store +} + +func TestRegisterProvider_Dispatch(t *testing.T) { + const authType = "custom-dispatch-test" + fake := &fakeProvider{token: &oauth2.Token{AccessToken: "tok"}} + RegisterProvider(authType, fake) + + profile := newProfileWithAuthCreds(t, profiles.AuthCredentials{AuthType: authType}) + + _, err := GetSDKAuthOptionFromProfile(profile) + require.NoError(t, err) + assert.True(t, fake.sdkCalled) + + require.NoError(t, ValidateProfileAuthCredentials(context.Background(), profile)) + assert.True(t, fake.validateCalled) + + tok, err := GetTokenWithProfile(context.Background(), profile) + require.NoError(t, err) + assert.True(t, fake.getTokenCalled) + assert.Equal(t, "tok", tok.AccessToken) +} + +func TestUnknownAuthType(t *testing.T) { + profile := newProfileWithAuthCreds(t, profiles.AuthCredentials{AuthType: "not-registered"}) + + _, err := GetSDKAuthOptionFromProfile(profile) + require.ErrorIs(t, err, ErrInvalidAuthType) + + err = ValidateProfileAuthCredentials(context.Background(), profile) + require.ErrorIs(t, err, ErrInvalidAuthType) + + _, err = GetTokenWithProfile(context.Background(), profile) + require.ErrorIs(t, err, ErrInvalidAuthType) +} + +func TestEmptyAuthType_Validate(t *testing.T) { + profile := newProfileWithAuthCreds(t, profiles.AuthCredentials{AuthType: ""}) + + err := ValidateProfileAuthCredentials(context.Background(), profile) + require.ErrorIs(t, err, ErrProfileCredentialsNotFound) +} + +func TestBuiltinAccessTokenValidation(t *testing.T) { + valid := newProfileWithAuthCreds(t, profiles.AuthCredentials{ + AuthType: profiles.AuthTypeAccessToken, + AccessToken: profiles.AuthCredentialsAccessToken{ + AccessToken: "abc", + Expiration: time.Now().Add(time.Hour).Unix(), + }, + }) + require.NoError(t, ValidateProfileAuthCredentials(context.Background(), valid)) + + expired := newProfileWithAuthCreds(t, profiles.AuthCredentials{ + AuthType: profiles.AuthTypeAccessToken, + AccessToken: profiles.AuthCredentialsAccessToken{ + AccessToken: "abc", + Expiration: time.Now().Add(-time.Hour).Unix(), + }, + }) + require.ErrorIs(t, ValidateProfileAuthCredentials(context.Background(), expired), ErrAccessTokenExpired) +} diff --git a/otdfctl/pkg/profiles/extensions.go b/otdfctl/pkg/profiles/extensions.go new file mode 100644 index 0000000000..43c1bb4f89 --- /dev/null +++ b/otdfctl/pkg/profiles/extensions.go @@ -0,0 +1,79 @@ +package profiles + +import ( + "encoding/json" + "sort" +) + +// GetExtension decodes the extension stored under name into a value of type T. +// A missing extension returns (zero, false, nil); a decode failure returns (zero, true, err). +func GetExtension[T any](p *OtdfctlProfileStore, name string) (T, bool, error) { + var v T + raw, ok := p.getRawExtension(name) + if !ok { + return v, false, nil + } + if err := json.Unmarshal(raw, &v); err != nil { + return v, true, err + } + return v, true, nil +} + +// SetExtension marshals v to JSON and stores it under name, then persists the profile. +func SetExtension[T any](p *OtdfctlProfileStore, name string, v T) error { + raw, err := json.Marshal(v) + if err != nil { + return err + } + return p.setRawExtension(name, raw) +} + +// HasExtension reports whether an extension is stored under name. +func (p *OtdfctlProfileStore) HasExtension(name string) bool { + _, ok := p.getRawExtension(name) + return ok +} + +// DeleteExtension removes the extension stored under name and persists the profile. +// It is a no-op if no such extension exists. +func (p *OtdfctlProfileStore) DeleteExtension(name string) error { + if p.config.Extensions == nil { + return nil + } + if _, ok := p.config.Extensions[name]; !ok { + return nil + } + delete(p.config.Extensions, name) + return p.store.Save() +} + +// ExtensionNames returns the sorted names of all extensions stored on the profile. +func (p *OtdfctlProfileStore) ExtensionNames() []string { + if len(p.config.Extensions) == 0 { + return nil + } + names := make([]string, 0, len(p.config.Extensions)) + for name := range p.config.Extensions { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// getRawExtension returns the raw JSON stored for an extension name. +func (p *OtdfctlProfileStore) getRawExtension(name string) (json.RawMessage, bool) { + if p.config.Extensions == nil { + return nil, false + } + raw, ok := p.config.Extensions[name] + return raw, ok +} + +// setRawExtension stores raw JSON under name (lazily initializing the map) and persists. +func (p *OtdfctlProfileStore) setRawExtension(name string, raw json.RawMessage) error { + if p.config.Extensions == nil { + p.config.Extensions = make(map[string]json.RawMessage) + } + p.config.Extensions[name] = raw + return p.store.Save() +} diff --git a/otdfctl/pkg/profiles/extensions_test.go b/otdfctl/pkg/profiles/extensions_test.go new file mode 100644 index 0000000000..1cac2866b9 --- /dev/null +++ b/otdfctl/pkg/profiles/extensions_test.go @@ -0,0 +1,113 @@ +package profiles + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type acmeCreds struct { + APIKey string `json:"apiKey"` + Region string `json:"region"` + Expiry int64 `json:"expiry"` +} + +type widgetConfig struct { + Enabled bool `json:"enabled"` + Tags []string `json:"tags"` +} + +func newTestMemoryStore(t *testing.T) *OtdfctlProfileStore { + t.Helper() + store, err := NewOtdfctlProfileStore(ProfileDriverMemory, &ProfileConfig{ + Name: "test-profile", + Endpoint: "http://localhost:8080", + }, true) + require.NoError(t, err) + require.NotNil(t, store) + return store +} + +func TestExtension_RoundTrip(t *testing.T) { + store := newTestMemoryStore(t) + + want := acmeCreds{APIKey: "secret", Region: "us-east", Expiry: 1_700_000_000} + require.NoError(t, SetExtension(store, "acme", want)) + + got, ok, err := GetExtension[acmeCreds](store, "acme") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, want, got) + // int64 must survive the store's JSON round-trip without float64 coercion. + assert.Equal(t, int64(1_700_000_000), got.Expiry) +} + +func TestExtension_HeterogeneousTypesCoexist(t *testing.T) { + store := newTestMemoryStore(t) + + require.NoError(t, SetExtension(store, "acme", acmeCreds{APIKey: "k"})) + require.NoError(t, SetExtension(store, "widget", widgetConfig{Enabled: true, Tags: []string{"a", "b"}})) + + acme, ok, err := GetExtension[acmeCreds](store, "acme") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "k", acme.APIKey) + + widget, ok, err := GetExtension[widgetConfig](store, "widget") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, widgetConfig{Enabled: true, Tags: []string{"a", "b"}}, widget) +} + +func TestExtension_MissingReturnsZeroValue(t *testing.T) { + store := newTestMemoryStore(t) + + got, ok, err := GetExtension[acmeCreds](store, "missing") + require.NoError(t, err) + assert.False(t, ok) + assert.Equal(t, acmeCreds{}, got) +} + +func TestExtension_DecodeErrorSurfaces(t *testing.T) { + store := newTestMemoryStore(t) + + // Store an object, then attempt to decode it into an incompatible type. + require.NoError(t, SetExtension(store, "acme", acmeCreds{APIKey: "k"})) + + _, ok, err := GetExtension[[]string](store, "acme") + assert.True(t, ok) + require.Error(t, err) +} + +func TestExtension_HasAndDelete(t *testing.T) { + store := newTestMemoryStore(t) + + assert.False(t, store.HasExtension("acme")) + // Deleting an absent extension is a no-op. + require.NoError(t, store.DeleteExtension("acme")) + + require.NoError(t, SetExtension(store, "acme", acmeCreds{APIKey: "k"})) + assert.True(t, store.HasExtension("acme")) + + require.NoError(t, store.DeleteExtension("acme")) + assert.False(t, store.HasExtension("acme")) +} + +func TestExtension_Names(t *testing.T) { + store := newTestMemoryStore(t) + + assert.Nil(t, store.ExtensionNames()) + + require.NoError(t, SetExtension(store, "widget", widgetConfig{})) + require.NoError(t, SetExtension(store, "acme", acmeCreds{})) + + assert.Equal(t, []string{"acme", "widget"}, store.ExtensionNames()) +} + +func TestExtension_OmittedFromJSONWhenEmpty(t *testing.T) { + b, err := json.Marshal(ProfileConfig{Name: "p", Endpoint: "http://localhost:8080"}) + require.NoError(t, err) + assert.NotContains(t, string(b), `"extensions"`) +} diff --git a/otdfctl/pkg/profiles/profileConfig.go b/otdfctl/pkg/profiles/profileConfig.go index c07e959383..267540fe4c 100644 --- a/otdfctl/pkg/profiles/profileConfig.go +++ b/otdfctl/pkg/profiles/profileConfig.go @@ -1,6 +1,7 @@ package profiles import ( + "encoding/json" "errors" "strings" @@ -25,6 +26,9 @@ type ProfileConfig struct { TLSNoVerify bool `json:"tlsNoVerify"` OutputFormat string `json:"outputFormat,omitempty"` AuthCredentials AuthCredentials `json:"authCredentials"` + // Extensions holds opaque, namespaced data for projects extending otdfctl. + // otdfctl does not interpret it; use GetExtension/SetExtension to access it. + Extensions map[string]json.RawMessage `json:"extensions,omitempty"` } func (pc *ProfileConfig) GetName() string {