diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7e33abcd..28266de0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -26,6 +26,9 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version-file: "go.mod" @@ -33,7 +36,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: - version: v2.1.0 + version: v2.10.1 # Optional: golangci-lint command line arguments. args: --timeout=10m only-new-issues: true diff --git a/cmd/auth/clientCredentials.go b/cmd/auth/clientCredentials.go index fda9d834..fdc7a052 100644 --- a/cmd/auth/clientCredentials.go +++ b/cmd/auth/clientCredentials.go @@ -2,6 +2,7 @@ package auth import ( "fmt" + "strings" "github.com/opentdf/otdfctl/cmd/common" "github.com/opentdf/otdfctl/pkg/auth" @@ -31,12 +32,24 @@ func clientCredentialsRun(cmd *cobra.Command, args []string) { if clientSecret == "" { clientSecret = cli.AskForSecret("Enter client secret: ") } + var scopes []string + if cmd.Flags().Changed("scopes") { + flagScopes, err := cmd.Flags().GetStringSlice("scopes") + if err != nil { + c.ExitWithError("Failed to read scopes flag", err) + } + scopes = make([]string, 0, len(flagScopes)) + for _, scope := range flagScopes { + scopes = append(scopes, strings.TrimSpace(scope)) + } + } // Set the client credentials err := cp.SetAuthCredentials(profiles.AuthCredentials{ AuthType: profiles.AuthTypeClientCredentials, ClientID: clientID, ClientSecret: clientSecret, + Scopes: scopes, }) if err != nil { c.ExitWithError("Failed to set client credentials", err) @@ -56,5 +69,10 @@ func newClientCredentialsCmd() *cobra.Command { man.WithRun(clientCredentialsRun), man.WithHiddenFlags("with-client-creds", "with-client-creds-file"), ) + doc.Flags().StringSlice( + doc.GetDocFlag("scopes").Name, + []string{}, + doc.GetDocFlag("scopes").Description, + ) return &doc.Command } diff --git a/cmd/common/common.go b/cmd/common/common.go index 779c95c3..5bdd315a 100644 --- a/cmd/common/common.go +++ b/cmd/common/common.go @@ -168,6 +168,7 @@ func NewHandler(c *cli.Cli) handlers.Handler { AuthType: profiles.AuthTypeClientCredentials, ClientID: cc.ClientID, ClientSecret: cc.ClientSecret, + Scopes: cc.Scopes, }); err != nil { cli.ExitWithError("Failed to set client credentials", err) } diff --git a/docs/man/_index.md b/docs/man/_index.md index 318cca97..bd72aa8c 100644 --- a/docs/man/_index.md +++ b/docs/man/_index.md @@ -26,9 +26,9 @@ command: - name: with-access-token description: access token for authentication via bearer token - name: with-client-creds-file - description: path to a JSON file containing a 'clientId' and 'clientSecret' for auth via client-credentials flow + description: path to a JSON file containing a 'clientId', 'clientSecret', and optional 'scopes' for auth via client-credentials flow - name: with-client-creds - description: JSON string containing a 'clientId' and 'clientSecret' for auth via client-credentials flow + description: JSON string containing a 'clientId', 'clientSecret', and optional 'scopes' for auth via client-credentials flow default: "" - name: json description: output in JSON format diff --git a/docs/man/auth/client-credentials.md b/docs/man/auth/client-credentials.md index 498afa98..a2a95270 100644 --- a/docs/man/auth/client-credentials.md +++ b/docs/man/auth/client-credentials.md @@ -7,6 +7,9 @@ command: - client-id arbitrary_args: - client-secret + flags: + - name: scopes + description: OIDC scopes to request (space-separated). --- > [!NOTE] @@ -40,3 +43,9 @@ Authenticate with client credentials (secret provided as argument) ```shell otdfctl auth client-credentials ``` + +Authenticate with client credentials and explicit scopes + +```shell +otdfctl auth client-credentials --scopes "api:access:read api:access:write" +``` diff --git a/go.mod b/go.mod index a9442bdb..fdfb8578 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/opentdf/otdfctl -go 1.24.0 +go 1.25.0 -toolchain go1.24.13 +toolchain go1.25.7 require ( github.com/adrg/frontmatter v0.2.0 @@ -18,9 +18,9 @@ require ( github.com/google/uuid v1.6.0 github.com/jrschumacher/go-osprofiles v0.0.0-20251201220924-3d077c5481e5 github.com/opentdf/platform/lib/flattening v0.1.3 - github.com/opentdf/platform/lib/ocrypto v0.9.0 - github.com/opentdf/platform/protocol/go v0.15.0 - github.com/opentdf/platform/sdk v0.12.0 + github.com/opentdf/platform/lib/ocrypto v0.10.0 + github.com/opentdf/platform/protocol/go v0.16.0 + github.com/opentdf/platform/sdk v0.13.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/zitadel/oidc/v3 v3.45.1 diff --git a/go.sum b/go.sum index a7774f57..4854bca3 100644 --- a/go.sum +++ b/go.sum @@ -238,12 +238,12 @@ github.com/opentdf/platform/lib/fixtures v0.4.0 h1:p3Y5MLJEBaWiSmo+QyRNTirvI8LqY github.com/opentdf/platform/lib/fixtures v0.4.0/go.mod h1:ctyrVn+eTObHAPy3vrdPO0O1mc3vgQ6lc9pBTdhBAfo= github.com/opentdf/platform/lib/flattening v0.1.3 h1:IuOm/wJVXNrzOV676Ticgr0wyBkL+lVjsoSfh+WSkNo= github.com/opentdf/platform/lib/flattening v0.1.3/go.mod h1:Gs/T+6FGZKk9OAdz2Jf1R8CTGeNRYrq1lZGDeYT3hrY= -github.com/opentdf/platform/lib/ocrypto v0.9.0 h1:ZEJRFLR549unvP6aMWt2j3HT29wqBBhO9P7uudho6Ho= -github.com/opentdf/platform/lib/ocrypto v0.9.0/go.mod h1:/TtiJldbP/LO1cvX8bwhnd7SVHSUImBt1EfjG9qEo78= -github.com/opentdf/platform/protocol/go v0.15.0 h1:7m1iBCxklQy/inIonmGJnhfjkr4ZFLXVt1dL5aiO+sY= -github.com/opentdf/platform/protocol/go v0.15.0/go.mod h1:m6hTbcBrtp2jRhsAstLvPSAnm8v055fUppveG3iI6tw= -github.com/opentdf/platform/sdk v0.12.0 h1:5LkVf5Ktjt5tsc5YBxloJUYNHJ9pE5IMqjswZwBwrRE= -github.com/opentdf/platform/sdk v0.12.0/go.mod h1:jLXYHV3Am2Fq5RSaCLUDLVocwA9iO7mJkGTUqX/HOr8= +github.com/opentdf/platform/lib/ocrypto v0.10.0 h1:7dn/z/1qH3p+gWCrfOoU7hj9XF/p5N+b2JBJuWF9aK0= +github.com/opentdf/platform/lib/ocrypto v0.10.0/go.mod h1:WASkoHreqgTFImB/gJW42VTdpi9AkgkmaW19y/fU+Ew= +github.com/opentdf/platform/protocol/go v0.16.0 h1:/EumdEZY7ujYyQF+EzCBCtIMiJPvGIZpgFgg8bORiFk= +github.com/opentdf/platform/protocol/go v0.16.0/go.mod h1:4lsBu86yrOWdhqIko8/x5ndamOtM8iDNZYBguF9ZiQQ= +github.com/opentdf/platform/sdk v0.13.0 h1:jhhCLE1Y57Y20g6TUEg/zqCfid8m4KPfbaaBlg6oAsM= +github.com/opentdf/platform/sdk v0.13.0/go.mod h1:x80F65+dGzxDTq8iqVIbCrbDDB9oYCYGjh4duoH6biM= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 48a694fe..ee9c0b1d 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -28,8 +28,9 @@ import ( const authCallbackPath = "/callback" type ClientCredentials struct { - ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` //nolint:gosec // not a hard-coded secret; populated at runtime + Scopes []string `json:"scopes,omitempty"` } type platformConfiguration struct { @@ -48,6 +49,27 @@ type JWTClaims struct { Expiration int64 `json:"exp"` } +func NormalizeScopes(scopes []string) []string { + if len(scopes) == 0 { + return nil + } + normalized := make([]string, 0, len(scopes)) + for _, scope := range scopes { + normalized = append(normalized, strings.Fields(scope)...) + } + if len(normalized) == 0 { + return nil + } + return normalized +} + +func normalizeClientCredScopes(creds *ClientCredentials) { + if creds == nil { + return + } + creds.Scopes = NormalizeScopes(creds.Scopes) +} + // Retrieves credentials by reading specified file func GetClientCredsFromFile(filepath string) (ClientCredentials, error) { creds := ClientCredentials{} @@ -60,6 +82,7 @@ func GetClientCredsFromFile(filepath string) (ClientCredentials, error) { if err := json.NewDecoder(f).Decode(&creds); err != nil { return creds, errors.Join(errors.New("failed to decode creds file"), err) } + normalizeClientCredScopes(&creds) return creds, nil } @@ -70,6 +93,7 @@ func GetClientCredsFromJSON(credsJSON []byte) (ClientCredentials, error) { if err := json.Unmarshal(credsJSON, &creds); err != nil { return creds, errors.Join(errors.New("failed to decode creds JSON"), err) } + normalizeClientCredScopes(&creds) return creds, nil } @@ -144,7 +168,7 @@ func GetSDKAuthOptionFromProfile(profile *profiles.OtdfctlProfileStore) (sdk.Opt switch c.AuthType { case profiles.AuthTypeClientCredentials: - return sdk.WithClientCredentials(c.ClientID, c.ClientSecret, nil), nil + return sdk.WithClientCredentials(c.ClientID, c.ClientSecret, NormalizeScopes(c.Scopes)), nil case profiles.AuthTypeAccessToken: tokenSource := oauth2.StaticTokenSource(buildToken(&c)) return sdk.WithOAuthAccessTokenSource(tokenSource), nil @@ -160,7 +184,7 @@ func ValidateProfileAuthCredentials(ctx context.Context, profile *profiles.Otdfc case "": return ErrProfileCredentialsNotFound case profiles.AuthTypeClientCredentials: - _, err := GetTokenWithClientCreds(ctx, profile.GetEndpoint(), c.ClientID, c.ClientSecret, profile.GetTLSNoVerify()) + _, err := GetTokenWithClientCreds(ctx, profile.GetEndpoint(), c.ClientID, c.ClientSecret, profile.GetTLSNoVerify(), c.Scopes) if err != nil { return err } @@ -180,7 +204,7 @@ func GetTokenWithProfile(ctx context.Context, profile *profiles.OtdfctlProfileSt switch c.AuthType { case profiles.AuthTypeClientCredentials: - return GetTokenWithClientCreds(ctx, profile.GetEndpoint(), c.ClientID, c.ClientSecret, profile.GetTLSNoVerify()) + return GetTokenWithClientCreds(ctx, profile.GetEndpoint(), c.ClientID, c.ClientSecret, profile.GetTLSNoVerify(), c.Scopes) case profiles.AuthTypeAccessToken: return buildToken(&c), nil default: @@ -189,7 +213,7 @@ func GetTokenWithProfile(ctx context.Context, profile *profiles.OtdfctlProfileSt } // Uses the OAuth2 client credentials flow to obtain a token. -func GetTokenWithClientCreds(ctx context.Context, endpoint string, clientID string, clientSecret string, tlsNoVerify bool) (*oauth2.Token, error) { +func GetTokenWithClientCreds(ctx context.Context, endpoint string, clientID string, clientSecret string, tlsNoVerify bool, scopes []string) (*oauth2.Token, error) { rp, err := newOidcRelyingParty(ctx, endpoint, tlsNoVerify, oidcClientCredentials{ clientID: clientID, clientSecret: clientSecret, @@ -197,7 +221,11 @@ func GetTokenWithClientCreds(ctx context.Context, endpoint string, clientID stri if err != nil { return nil, err } - return oidcrp.ClientCredentials(ctx, rp, url.Values{}) + params := url.Values{} + if normalized := NormalizeScopes(scopes); len(normalized) > 0 { + params.Set("scope", strings.Join(normalized, " ")) + } + return oidcrp.ClientCredentials(ctx, rp, params) } const ( diff --git a/pkg/profiles/profileAuthCreds.go b/pkg/profiles/profileAuthCreds.go index fd795f01..10db5b98 100644 --- a/pkg/profiles/profileAuthCreds.go +++ b/pkg/profiles/profileAuthCreds.go @@ -10,6 +10,7 @@ type AuthCredentials struct { ClientID string `json:"clientId"` // Used for client credentials ClientSecret string `json:"clientSecret,omitempty"` + Scopes []string `json:"scopes,omitempty"` AccessToken AuthCredentialsAccessToken `json:"accessToken,omitempty"` }