diff --git a/cmd/auth-clearCachedCredentials.go b/cmd/auth-clearCachedCredentials.go index 966023a6..39e8e464 100644 --- a/cmd/auth-clearCachedCredentials.go +++ b/cmd/auth-clearCachedCredentials.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" ) -var clearCachedCredsCmd = man.Docs.GetCommand("auth/clear-cached-credentials", +var auth_clearClientCredentialsCmd = man.Docs.GetCommand("auth/clear-client-credentials", man.WithRun(auth_clearCreds), man.WithHiddenFlags("with-client-creds", "with-client-creds-file"), ) @@ -18,9 +18,22 @@ func auth_clearCreds(cmd *cobra.Command, args []string) { flagHelper := cli.NewFlagHelper(cmd) host := flagHelper.GetRequiredString("host") - if err := handlers.ClearCachedCredentials(host); err != nil { - cli.ExitWithError("Failed to clear cached client credentials and token", err) + p := cli.NewPrinter(true) + + p.Printf("Clearing cached client credentials for %s... ", host) + if err := handlers.NewKeyring(host).DeleteClientCredentials(); err != nil { + fmt.Println("failed") + cli.ExitWithError("Failed to clear cached client credentials", err) } + p.Println("ok") +} + +func init() { + auth_clearClientCredentialsCmd.Flags().String( + auth_clearClientCredentialsCmd.GetDocFlag("all").Name, + auth_clearClientCredentialsCmd.GetDocFlag("all").Description, + auth_clearClientCredentialsCmd.GetDocFlag("all").Default, + ) - fmt.Println(cli.SuccessMessage("Cached client credentials and token are clear.")) + authCmd.AddCommand(&auth_clearClientCredentialsCmd.Command) } diff --git a/cmd/auth-clientCredentials.go b/cmd/auth-clientCredentials.go index 9f8d8a72..c2bb64bc 100644 --- a/cmd/auth-clientCredentials.go +++ b/cmd/auth-clientCredentials.go @@ -1,9 +1,7 @@ package cmd import ( - "errors" "fmt" - "log/slog" "github.com/opentdf/otdfctl/pkg/cli" "github.com/opentdf/otdfctl/pkg/handlers" @@ -11,84 +9,49 @@ import ( "github.com/spf13/cobra" ) -var ( - clientCredentialsCmd = man.Docs.GetCommand("auth/client-credentials", - man.WithRun(auth_clientCredentials), - ) - noCacheCreds bool +var clientCredentialsCmd = man.Docs.GetCommand("auth/client-credentials", + man.WithRun(auth_clientCredentials), + man.WithHiddenFlags("with-client-creds", "with-client-creds-file"), ) func auth_clientCredentials(cmd *cobra.Command, args []string) { - var err error + var c handlers.ClientCredentials flagHelper := cli.NewFlagHelper(cmd) host := flagHelper.GetRequiredString("host") tlsNoVerify := flagHelper.GetOptionalBool("tls-no-verify") - clientID := flagHelper.GetOptionalString("client-id") - clientSecret := flagHelper.GetOptionalString("client-secret") - slog.Debug("Checking for client credentials file", slog.String("with-client-creds-file", clientCredsFile)) - if clientCredsFile != "" { - creds, err := handlers.GetClientCredsFromFile(clientCredsFile) - if err != nil { - cli.ExitWithError("Failed to parse client credentials JSON", err) - } - clientID = creds.ClientID - clientSecret = creds.ClientSecret - } + p := cli.NewPrinter(true) - // if not provided by flag, check keyring cache for clientID - if clientID == "" { - slog.Debug("No client-id provided. Attempting to retrieve the default from keyring.") - clientID, err = handlers.GetClientIDFromCache(host) - if err != nil || clientID == "" { - cli.ExitWithError("Please provide required flag: (client-id)", errors.New("no client-id found")) - } else { - slog.Debug(cli.SuccessMessage("Retrieved stored client-id from keyring")) - } + if len(args) > 0 { + c.ClientId = args[0] + } + if len(args) > 1 { + c.ClientSecret = args[1] } - // check if we have a clientSecret in the keyring, if a null value is passed in - if clientSecret == "" { - clientSecret, err = handlers.GetClientSecretFromCache(host, clientID) - if err == nil || clientSecret == "" { - cli.ExitWithError("Please provide required flag: (client-secret)", errors.New("no client-secret found")) - } else { - slog.Debug("Retrieved stored client-secret from keyring") - } + if c.ClientId == "" { + c.ClientId = cli.AskForInput("Enter client id: ") + } + if c.ClientSecret == "" { + c.ClientSecret = cli.AskForSecret("Enter client secret: ") } - slog.Debug("Attempting to login with client credentials", slog.String("client-id", clientID)) - if err := handlers.GetTokenWithClientCreds(cmd.Context(), host, clientID, clientSecret, tlsNoVerify); err != nil { + p.Printf("Logging in with client ID and secret for %s... ", host) + if _, err := handlers.GetTokenWithClientCreds(cmd.Context(), host, c, tlsNoVerify); err != nil { + fmt.Println("failed") cli.ExitWithError("An error occurred during login. Please check your credentials and try again", err) } + p.Println("ok") - fmt.Println(cli.SuccessMessage("Successfully logged in with client ID and secret")) + p.Print("Storing client ID and secret in keyring... ") + if err := handlers.NewKeyring(host).SetClientCredentials(c); err != nil { + fmt.Println("failed") + cli.ExitWithError("Failed to cache client credentials", err) + } + p.Println("ok") } func init() { - clientCredentialsCmd := man.Docs.GetCommand("auth/client-credentials", - man.WithRun(auth_clientCredentials), - // use the individual client-id and client-secret flags here instead of the global with-client-creds flag - man.WithHiddenFlags("with-client-creds", "with-client-creds-file"), - ) - clientCredentialsCmd.Flags().StringP( - clientCredentialsCmd.GetDocFlag("client-id").Name, - clientCredentialsCmd.GetDocFlag("client-id").Shorthand, - clientCredentialsCmd.GetDocFlag("client-id").Default, - clientCredentialsCmd.GetDocFlag("client-id").Description, - ) - clientCredentialsCmd.Flags().StringP( - clientCredentialsCmd.GetDocFlag("client-secret").Name, - clientCredentialsCmd.GetDocFlag("client-secret").Shorthand, - clientCredentialsCmd.GetDocFlag("client-secret").Default, - clientCredentialsCmd.GetDocFlag("client-secret").Description, - ) - clientCredentialsCmd.Flags().BoolVarP( - &noCacheCreds, - clientCredentialsCmd.GetDocFlag("no-cache").Name, - clientCredentialsCmd.GetDocFlag("no-cache").Shorthand, - clientCredentialsCmd.GetDocFlag("no-cache").DefaultAsBool(), - clientCredentialsCmd.GetDocFlag("no-cache").Description, - ) + authCmd.AddCommand(&clientCredentialsCmd.Command) } diff --git a/cmd/auth-printAccessToken.go b/cmd/auth-printAccessToken.go index 96d13c3a..a862198c 100644 --- a/cmd/auth-printAccessToken.go +++ b/cmd/auth-printAccessToken.go @@ -1,6 +1,8 @@ package cmd import ( + "context" + "encoding/json" "fmt" "github.com/opentdf/otdfctl/pkg/cli" @@ -9,17 +11,56 @@ import ( "github.com/spf13/cobra" ) -var printAccessToken = man.Docs.GetCommand("auth/print-access-token", - man.WithRun(auth_printAccessToken), -) +var auth_printAccessTokenCmd = man.Docs.GetCommand("auth/print-access-token", + man.WithRun(auth_printAccessToken)) func auth_printAccessToken(cmd *cobra.Command, args []string) { flagHelper := cli.NewFlagHelper(cmd) host := flagHelper.GetRequiredString("host") + jsonOut := flagHelper.GetOptionalBool("json") + + printEnabled := !jsonOut + p := cli.NewPrinter(printEnabled) + + p.Printf("Getting stored client credentials for %s... ", host) + clientCredentials, err := handlers.NewKeyring(host).GetClientCredentials() + if err != nil { + p.Println("failed") + cli.ExitWithError("Client credentials not found. Please use `auth client-credentials` to set them", err) + } + p.Println("ok") - tok, err := handlers.GetOIDCTokenFromCache(host) + p.Printf("Getting access token for %s... ", clientCredentials.ClientId) + tok, err := handlers.GetTokenWithClientCreds( + context.Background(), + host, + clientCredentials, + flagHelper.GetOptionalBool("tls-no-verify"), + ) if err != nil { - cli.ExitWithError("Failed to get OIDC token from cache", err) + p.Println("failed") + cli.ExitWithError("Failed to get token", err) } - fmt.Print(tok) + p.Println("ok") + p.Printf("Access Token: %s\n", tok.AccessToken) + + if jsonOut { + d, err := json.MarshalIndent(tok, "", " ") + if err != nil { + cli.ExitWithError("Failed to marshal token to json", err) + } + + fmt.Println(string(d)) + return + } +} + +func init() { + auth_printAccessTokenCmd.Flags().Bool( + auth_printAccessTokenCmd.GetDocFlag("json").Name, + auth_printAccessTokenCmd.GetDocFlag("json").DefaultAsBool(), + auth_printAccessTokenCmd.GetDocFlag("json").Description, + ) + + authCmd.AddCommand(&auth_printAccessTokenCmd.Command) } diff --git a/cmd/auth.go b/cmd/auth.go index c973586b..f89eea2e 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -8,14 +8,15 @@ import ( "github.com/spf13/cobra" ) +var authCmd = man.Docs.GetCommand("auth", man.WithHiddenFlags( + "with-client-creds", + "with-client-creds-file", +)) + func init() { - cmd := man.Docs.GetCommand("auth", - man.WithSubcommands(clientCredentialsCmd), - man.WithSubcommands(printAccessToken), - man.WithSubcommands(clearCachedCredsCmd), - ) + RootCmd.AddCommand(&authCmd.Command) - cmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + authCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { // not supported on linux if runtime.GOOS == "linux" { cli.ExitWithWarning( @@ -24,6 +25,4 @@ func init() { ) } } - - RootCmd.AddCommand(&cmd.Command) } diff --git a/cmd/dev.go b/cmd/dev.go index 091b4ed7..96cdc2a0 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -172,7 +172,7 @@ func NewHandler(cmd *cobra.Command) handlers.Handler { cli.ExitWithError("Failed to get client credentials", err) } - h, err := handlers.NewWithCredentials(host, creds.ClientID, creds.ClientSecret, tlsNoVerify) + h, err := handlers.NewWithCredentials(host, creds.ClientId, creds.ClientSecret, tlsNoVerify) if err != nil { if errors.Is(err, handlers.ErrUnauthenticated) { cli.ExitWithError(fmt.Sprintf("Not logged in. Please authenticate via CLI auth flow(s) before using command (%s %s)", cmd.Parent().Use, cmd.Use), err) diff --git a/docs/man/auth/clear-cached-credentials.md b/docs/man/auth/clear-cached-credentials.md deleted file mode 100644 index 5da5fb31..00000000 --- a/docs/man/auth/clear-cached-credentials.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: Clear any cached OIDC access token, clientId, and clientSecret granted through the client credentials flow - -command: - name: clear-cached-credentials ---- - -Clear any cached OIDC access token, `clientId`, and `clientSecret` on the native OS granted through the client credentials flow. diff --git a/docs/man/auth/clear-client-credentials.md b/docs/man/auth/clear-client-credentials.md new file mode 100644 index 00000000..4e858ffe --- /dev/null +++ b/docs/man/auth/clear-client-credentials.md @@ -0,0 +1,12 @@ +--- +title: Clear the cached client credentials + +command: + name: clear-client-credentials + flags: + - name: all + description: Clear all cached client credentials + default: false +--- + +Clear the cached client credentials from the OS keyring for the current platform endpoint. diff --git a/docs/man/auth/client-credentials.md b/docs/man/auth/client-credentials.md index 4fdc6709..47ccedd7 100644 --- a/docs/man/auth/client-credentials.md +++ b/docs/man/auth/client-credentials.md @@ -3,17 +3,11 @@ title: Authenticate to the platform with the client-credentials flow command: name: client-credentials - flags: - - name: client-id - description: A clientId for use in client-credentials auth flow - shorthand: i - required: true - - name: client-secret - description: A clientSecret for use in client-credentials auth flow - shorthand: s - - name: no-cache - description: Do not cache credentials on the native OS and print access token to stdout instead + args: + - client-id + arbitrary_args: + - client-secret --- -Allows the user to login in via Client ID and Secret. The client credentials and OIDC Access Token will be stored -in the OS-specific keychain by default, otherwise printed to `stdout` if `--no-cache` is passed. +Allows the user to login in via Client Credentials flow. The client credentials will be stored safely +in the OS keyring for future use. diff --git a/docs/man/auth/print-access-token.md b/docs/man/auth/print-access-token.md index aa11536c..376f97c9 100644 --- a/docs/man/auth/print-access-token.md +++ b/docs/man/auth/print-access-token.md @@ -3,6 +3,10 @@ title: Print the cached OIDC access token (if found) command: name: print-access-token + flags: + - name: json + description: Print the full token in JSON format + default: false --- -Retrieves the cached OIDC Access Token from the OS-specific keychain and prints to stdout if found. +Retrieves a new OIDC Access Token using the client credentials from the OS-specific keychain and prints to stdout if found. diff --git a/go.mod b/go.mod index 91450250..6c710ae9 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 github.com/zalando/go-keyring v0.2.5 + golang.org/x/oauth2 v0.22.0 golang.org/x/term v0.22.0 google.golang.org/grpc v1.65.0 google.golang.org/protobuf v1.34.2 @@ -47,10 +48,14 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/gowebpki/jcs v1.0.1 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect @@ -75,6 +80,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/opentdf/platform/lib/ocrypto v0.1.5 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect @@ -85,6 +91,7 @@ require ( github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect github.com/segmentio/asm v1.2.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect @@ -96,6 +103,12 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.5.4 // indirect github.com/yuin/goldmark-emoji v1.0.2 // indirect + github.com/zitadel/logging v0.6.0 // indirect + github.com/zitadel/oidc/v3 v3.27.0 // indirect + github.com/zitadel/schema v1.3.0 // indirect + go.opentelemetry.io/otel v1.28.0 // indirect + go.opentelemetry.io/otel/metric v1.28.0 // indirect + go.opentelemetry.io/otel/trace v1.28.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.25.0 // indirect golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect diff --git a/go.sum b/go.sum index 8d9c336b..48a1297a 100644 --- a/go.sum +++ b/go.sum @@ -95,8 +95,13 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4= github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= @@ -121,6 +126,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU= github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 h1:pRhl55Yx1eC7BZ1N+BBWwnKaMyD8uC+34TLdndZMAKk= @@ -195,6 +202,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -294,14 +303,26 @@ github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFi github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8= github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= +github.com/zitadel/logging v0.6.0 h1:t5Nnt//r+m2ZhhoTmoPX+c96pbMarqJvW1Vq6xFTank= +github.com/zitadel/logging v0.6.0/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= +github.com/zitadel/oidc/v3 v3.27.0 h1:zeYpyRH0UcgdCjVHUYzSsqf1jbSwVMPVxYKOnRXstgU= +github.com/zitadel/oidc/v3 v3.27.0/go.mod h1:ZwBEqSviCpJVZiYashzo53bEGRGXi7amE5Q8PpQg9IM= +github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= +github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= +go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= +go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= +go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= @@ -312,9 +333,14 @@ golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= +golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= +golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/pkg/cli/confirm.go b/pkg/cli/confirm.go index 5b6f39d6..da325864 100644 --- a/pkg/cli/confirm.go +++ b/pkg/cli/confirm.go @@ -56,3 +56,28 @@ func ConfirmTextInput(action, resource, inputName, shouldMatchValue string) { ExitWithError("Confirmation prompt failed", err) } } + +func AskForInput(message string) string { + var input string + err := huh.NewInput(). + Value(&input). + Title(message). + Run() + if err != nil { + ExitWithError("Prompt for input failed", err) + } + return input +} + +func AskForSecret(message string) string { + var secret string + err := huh.NewInput(). + Value(&secret). + Title(message). + EchoMode(huh.EchoModePassword). + Run() + if err != nil { + ExitWithError("Prompt for secret failed", err) + } + return secret +} diff --git a/pkg/cli/printer.go b/pkg/cli/printer.go new file mode 100644 index 00000000..dd1463f4 --- /dev/null +++ b/pkg/cli/printer.go @@ -0,0 +1,31 @@ +package cli + +import "fmt" + +type Printer struct { + enabled bool +} + +func NewPrinter(enabled bool) *Printer { + return &Printer{ + enabled: enabled, + } +} + +func (p *Printer) Print(args ...interface{}) { + if p.enabled { + fmt.Print(args...) + } +} + +func (p *Printer) Printf(format string, args ...interface{}) { + if p.enabled { + fmt.Printf(format, args...) + } +} + +func (p *Printer) Println(args ...interface{}) { + if p.enabled { + fmt.Println(args...) + } +} diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index a6eb9149..dc27b1a2 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -4,104 +4,28 @@ import ( "context" "encoding/json" "errors" - "fmt" + "net/url" "os" - "time" - "github.com/golang-jwt/jwt/v4" - "github.com/zalando/go-keyring" + oidcrp "github.com/zitadel/oidc/v3/pkg/client/rp" + "golang.org/x/oauth2" ) const ( - OTDFCTL_CLIENT_ID_CACHE_KEY = "OTDFCTL_DEFAULT_CLIENT_ID" - OTDFCTL_OIDC_TOKEN_KEY = "OTDFCTL_OIDC_TOKEN" + OTDFCTL_KEYRING_SERVICE = "otdfctl" + OTDFCTL_CLIENT_ID_CACHE_KEY = "OTDFCTL_DEFAULT_CLIENT_ID" + OTDFCTL_KEYRING_CLIENT_CREDENTIALS = "OTDFCTL_CLIENT_CREDENTIALS" + OTDFCTL_OIDC_TOKEN_KEY = "OTDFCTL_OIDC_TOKEN" ) -// CheckTokenExpiration checks if an OIDC token has expired. -// Returns true if the token is still valid, false otherwise. -func CheckTokenExpiration(tokenString string) (bool, error) { - // for simplicity sake, we're skipping the token validation, and just checking the expiration time, if expired we'll get a new token - token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{}) - if err != nil { - return false, err // Token could not be parsed - } - - if claims, ok := token.Claims.(jwt.MapClaims); ok { - if exp, ok := claims["exp"].(float64); ok { - expirationTime := time.Unix(int64(exp), 0) - return time.Now().Before(expirationTime), nil // Return true if the current time is before the expiration time - } - } - - // Return an error if the expiration time could not be found or parsed - return false, fmt.Errorf("expiration time (exp) claim is missing or invalid") -} - -func ClearCachedCredentials(endpoint string) error { - cachedClientID, err := GetClientIDFromCache(endpoint) - if err != nil { - if errors.Is(err, keyring.ErrNotFound) { - fmt.Println("No client-id found in the cache to clear.") - } else { - return errors.Join(errors.New("failed to retrieve client id from keyring"), err) - } - } - - // clear the client ID and secret from the keyring - err = keyring.Delete(endpoint, cachedClientID) - if err != nil { - if errors.Is(err, keyring.ErrNotFound) { - fmt.Println("No client secret found in the cache to clear under client-id: ", cachedClientID) - } else { - return errors.Join(errors.New("failed to clear client secret from keyring"), err) - } - } - - err = keyring.Delete(endpoint, OTDFCTL_CLIENT_ID_CACHE_KEY) - if err != nil { - if errors.Is(err, keyring.ErrNotFound) { - fmt.Println("No client id found in the cache to clear.") - } else { - return errors.Join(errors.New("failed to clear client id from keyring"), err) - } - } - - err = keyring.Delete(endpoint, OTDFCTL_OIDC_TOKEN_KEY) - if err != nil { - if errors.Is(err, keyring.ErrNotFound) { - fmt.Println("No token found in the cache to clear.") - } else { - return errors.Join(errors.New("failed to clear token from keyring"), err) - } - } - - return nil -} - -// GetOIDCTokenFromCache retrieves the OIDC token from the keyring. -func GetOIDCTokenFromCache(endpoint string) (string, error) { - return keyring.Get(endpoint, OTDFCTL_OIDC_TOKEN_KEY) -} - -// GetClientIDFromCache retrieves the client ID from the keyring. -func GetClientIDFromCache(endpoint string) (string, error) { - return keyring.Get(endpoint, OTDFCTL_CLIENT_ID_CACHE_KEY) -} - -// GetClientSecretFromCache retrieves the client secret from the keyring. -func GetClientSecretFromCache(endpoint string, clientID string) (string, error) { - return keyring.Get(endpoint, clientID) -} - -// Client ID and Secret for use in the client credentials flow. -type ClientCreds struct { - ClientID string `json:"clientId"` +type ClientCredentials struct { + ClientId string `json:"clientId"` ClientSecret string `json:"clientSecret"` } // Retrieves credentials by reading specified file -func GetClientCredsFromFile(filepath string) (ClientCreds, error) { - creds := ClientCreds{} +func GetClientCredsFromFile(filepath string) (ClientCredentials, error) { + creds := ClientCredentials{} f, err := os.Open(filepath) if err != nil { return creds, errors.Join(errors.New("failed to open creds file"), err) @@ -116,8 +40,8 @@ func GetClientCredsFromFile(filepath string) (ClientCreds, error) { } // Parse the JSON and return the client ID and secret -func GetClientCredsFromJSON(credsJSON []byte) (ClientCreds, error) { - creds := ClientCreds{} +func GetClientCredsFromJSON(credsJSON []byte) (ClientCredentials, error) { + creds := ClientCredentials{} if err := json.Unmarshal(credsJSON, &creds); err != nil { return creds, errors.Join(errors.New("failed to decode creds JSON"), err) } @@ -125,53 +49,37 @@ func GetClientCredsFromJSON(credsJSON []byte) (ClientCreds, error) { return creds, nil } -// Retrieves the client secret from the keyring. -func GetClientCredsFromCache(endpoint string) (ClientCreds, error) { - creds := ClientCreds{} - // we use the client id to cache the secret, so retrieve it first - clientID, err := keyring.Get(endpoint, OTDFCTL_CLIENT_ID_CACHE_KEY) - if err != nil || clientID == "" { - return creds, errors.Join(errors.New("could not find clientID in OS keyring"), ErrUnauthenticated) - } - - clientSecret, err := keyring.Get(endpoint, clientID) - if err != nil { - return creds, err - } - return ClientCreds{ - ClientID: clientID, - ClientSecret: clientSecret, - }, nil -} - -func GetClientCreds(endpoint string, file string, credsJSON []byte) (ClientCreds, error) { +func GetClientCreds(endpoint string, file string, credsJSON []byte) (ClientCredentials, error) { if file != "" { return GetClientCredsFromFile(file) } if len(credsJSON) > 0 { return GetClientCredsFromJSON(credsJSON) } - return GetClientCredsFromCache(endpoint) + return NewKeyring(endpoint).GetClientCredentials() } -// Uses the OAuth2 client credentials flow to obtain a token. -func GetTokenWithClientCreds(ctx context.Context, endpoint string, clientID string, clientSecret string, tlsNoVerify bool) error { - // TODO improve the way we validate the client credentials - // sdk, err := NewWithCredentials(endpoint, clientID, clientSecret, tlsNoVerify) - // if err != nil { - // return err - // } +func getPlatformIssuer(endpoint string, tlsNoVerify bool) (string, error) { + // Create a new handler with the provided endpoint and no credentials (empty strings is required by the SDK) + h, err := NewWithCredentials(endpoint, "", "", tlsNoVerify) + if err != nil { + return "", err + } - // if _, err := sdk.Direct().Authorization.GetDecisions(ctx, &authorization.GetDecisionsRequest{}); err != nil { - // return errors.Join(errors.New("failed to get token with client credentials"), err) - // } + return h.sdk.PlatformIssuer(), nil +} - if err := keyring.Set(endpoint, clientID, clientSecret); err != nil { - return fmt.Errorf("failed to store client secret in key: %v", err) +// Uses the OAuth2 client credentials flow to obtain a token. +func GetTokenWithClientCreds(ctx context.Context, endpoint string, c ClientCredentials, tlsNoVerify bool) (*oauth2.Token, error) { + issuer, err := getPlatformIssuer(endpoint, tlsNoVerify) + if err != nil { + return nil, err } - if err := keyring.Set(endpoint, OTDFCTL_CLIENT_ID_CACHE_KEY, clientID); err != nil { - return fmt.Errorf("failed to store client ID in keyring: %v", err) + + rp, err := oidcrp.NewRelyingPartyOIDC(ctx, issuer, c.ClientId, c.ClientSecret, "", []string{"email"}) + if err != nil { + return nil, err } - return nil + return oidcrp.ClientCredentials(ctx, rp, url.Values{}) } diff --git a/pkg/handlers/keyring.go b/pkg/handlers/keyring.go new file mode 100644 index 00000000..590763c9 --- /dev/null +++ b/pkg/handlers/keyring.go @@ -0,0 +1,73 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "errors" + + "github.com/zalando/go-keyring" +) + +type Keyring struct { + endpoint string +} + +func NewKeyring(endpoint string) *Keyring { + return &Keyring{ + endpoint: endpoint, + } +} + +func (k *Keyring) getService() string { + return OTDFCTL_KEYRING_SERVICE + "-" + k.endpoint +} + +func (k *Keyring) get(key string, value *map[string]interface{}) error { + s, err := keyring.Get(k.getService(), key) + if err != nil { + return err + } + return json.NewDecoder(bytes.NewReader([]byte(s))).Decode(value) +} + +func (k *Keyring) set(key string, value any) error { + var b bytes.Buffer + if err := json.NewEncoder(&b).Encode(value); err != nil { + return err + } + return keyring.Set(k.getService(), key, b.String()) +} + +func (k *Keyring) delete(key string) error { + return keyring.Delete(k.getService(), key) +} + +func (k *Keyring) GetClientCredentials() (ClientCredentials, error) { + var v map[string]interface{} + var c ClientCredentials + + if err := k.get(OTDFCTL_KEYRING_CLIENT_CREDENTIALS, &v); err != nil { + return c, err + } else if v == nil { + return c, errors.New("client credentials not found") + } + if _, ok := v["clientId"]; !ok { + return c, errors.New("client_id not found") + } + c.ClientId = v["clientId"].(string) + + if _, ok := v["clientSecret"]; !ok { + return c, errors.New("client_secret not found") + } + c.ClientSecret = v["clientSecret"].(string) + + return c, nil +} + +func (k *Keyring) SetClientCredentials(c ClientCredentials) error { + return k.set(OTDFCTL_KEYRING_CLIENT_CREDENTIALS, c) +} + +func (k *Keyring) DeleteClientCredentials() error { + return k.delete(OTDFCTL_KEYRING_CLIENT_CREDENTIALS) +} diff --git a/pkg/man/man.go b/pkg/man/man.go index e8471584..dbcd9c8f 100644 --- a/pkg/man/man.go +++ b/pkg/man/man.go @@ -61,7 +61,7 @@ func WithHiddenFlags(flags ...string) CommandOpts { for _, f := range flags { command.Flags().MarkHidden(f) } - command.Parent().HelpFunc()(command, strings) + d.Parent().HelpFunc()(command, strings) }) } } @@ -192,15 +192,17 @@ func init() { func processDoc(doc string) (*Doc, error) { if len(doc) <= 0 { - return nil, fmt.Errorf("Empty document") + return nil, fmt.Errorf("empty document") } var matter struct { Title string `yaml:"title"` Command struct { - Name string `yaml:"name"` - Hidden bool `yaml:"hidden"` - Aliases []string `yaml:"aliases"` - Flags []DocFlag `yaml:"flags"` + Name string `yaml:"name"` + Args []string `yaml:"arguments"` + ArbitraryArgs []string `yaml:"arbitraryArgs"` + Hidden bool `yaml:"hidden"` + Aliases []string `yaml:"aliases"` + Flags []DocFlag `yaml:"flags"` } `yaml:"command"` } rest, err := frontmatter.Parse(strings.NewReader(doc), &matter) @@ -216,9 +218,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 { + args = cobra.ExactArgs(len(c.Args)) + } + if len(c.ArbitraryArgs) > 0 { + args = cobra.ArbitraryArgs + } + d := Doc{ cobra.Command{ Use: c.Name, + Args: args, Hidden: c.Hidden, Aliases: c.Aliases, Short: matter.Title,