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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions otdfctl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
55 changes: 19 additions & 36 deletions otdfctl/pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment on lines 167 to 173

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Add a nil check for profile to prevent potential nil pointer dereference panics if the function is called with a nil profile store.

Suggested change
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)
}
func GetSDKAuthOptionFromProfile(profile *profiles.OtdfctlProfileStore) (sdk.Option, error) {
if profile == nil {
return nil, ErrProfileCredentialsNotFound
}
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)
}
Comment on lines 177 to 187

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Add a nil check for profile to prevent potential nil pointer dereference panics if the function is called with a nil profile store.

func ValidateProfileAuthCredentials(ctx context.Context, profile *profiles.OtdfctlProfileStore) error {
	if profile == nil {
		return ErrProfileCredentialsNotFound
	}
	authType := profile.GetAuthCredentials().AuthType
	if authType == "" {
		return ErrProfileCredentialsNotFound
	}
	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)
}
Comment on lines 190 to 196

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Add a nil check for profile to prevent potential nil pointer dereference panics if the function is called with a nil profile store.

func GetTokenWithProfile(ctx context.Context, profile *profiles.OtdfctlProfileStore) (*oauth2.Token, error) {
	if profile == nil {
		return nil, ErrProfileCredentialsNotFound
	}
	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.
Expand Down
85 changes: 85 additions & 0 deletions otdfctl/pkg/auth/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package auth

import (
"context"

"github.com/opentdf/platform/otdfctl/pkg/profiles"
"github.com/opentdf/platform/sdk"
"golang.org/x/oauth2"
)
Comment on lines +3 to +9

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import the sync package to support thread-safe access to the global authProviders map.

Suggested change
import (
"context"
"github.com/opentdf/platform/otdfctl/pkg/profiles"
"github.com/opentdf/platform/sdk"
"golang.org/x/oauth2"
)
import (
"context"
"sync"
"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
}
Comment on lines +23 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use a sync.RWMutex to protect the global authProviders map from concurrent read/write race conditions and potential panics if providers are registered or looked up concurrently at runtime.

var (
	authProvidersMu sync.RWMutex
	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) {
	authProvidersMu.Lock()
	defer authProvidersMu.Unlock()
	authProviders[authType] = provider
}

// lookupProvider returns the provider for authType, or ErrInvalidAuthType if none.
func lookupProvider(authType string) (Provider, error) {
	authProvidersMu.RLock()
	defer authProvidersMu.RUnlock()
	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
}
106 changes: 106 additions & 0 deletions otdfctl/pkg/auth/provider_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading