diff --git a/README.md b/README.md index 29181a5..4f40391 100644 --- a/README.md +++ b/README.md @@ -188,13 +188,16 @@ Global Flags: Get info about a certificate and a key and see if their public keys match: ```shell -❯ https-wrench certinfo --cert-bundle rsa-pkcs8-crt.pem --key-file rsa-pkcs8-plaintext-private-key.pem +❯ https-wrench certinfo \ + --cert-bundle rsa-pkcs8-crt.pem \ + --key-file rsa-pkcs8-plaintext-private-key.pem ``` Get info about a certificate exposed by a remote TLS endpoint: ```shell -❯ https-wrench certinfo --tls-endpoint repo.os76.xyz:443 +❯ https-wrench certinfo \ + --tls-endpoint repo.os76.xyz:443 ``` Get info about a self signed certificate exposed by a remote TLS endpoint, @@ -202,7 +205,10 @@ validate it against a CA certificate and check if a specific privave key has been used to generate the certificate: ```shell -❯ https-wrench certinfo --tls-endpoint localhost:9443 --ca-bundle rootCA.pem --key-file key.pem +❯ https-wrench certinfo \ + --tls-endpoint localhost:9443 \ + --ca-bundle rootCA.pem \ + --key-file key.pem ``` ### HTTPS Wrench jwtinfo @@ -226,16 +232,34 @@ Examples: https-wrench jwtinfo --token-file /var/run/secrets/kubernetes.io/serviceaccount/token # Request a JWT token using inline values - https-wrench jwtinfo --request-url $REQ_URL --request-values-json $REQ_VALUES + https-wrench jwtinfo \ + --request-url $REQ_URL \ + --request-values-json $REQ_VALUES # Request a JWT token using values file - https-wrench jwtinfo --request-url $REQ_URL --request-values-file request-values.json + https-wrench jwtinfo \ + --request-url $REQ_URL \ + --request-values-file request-values.json + + # Request a JWT token using request-values flag + https-wrench jwtinfo \ + --request-url $REQ_URL \ + --request-values username=test \ + --request-values password=test \ + --request-values scope=login # Request and validate a JWT token - https-wrench jwtinfo --request-url $REQ_URL --request-values-json $REQ_VALUES --validation-url $VALIDATION_URL + https-wrench jwtinfo \ + --request-url $REQ_URL \ + --request-values-json $REQ_VALUES \ + --validation-url $VALIDATION_URL # Request a JWT token, write it to a file and refresh it before expiration - https-wrench jwtinfo --request-url $REQ_URL --request-values-json $REQ_VALUES --token-output-file /tmp/token --refresh + https-wrench jwtinfo \ + --request-url $REQ_URL \ + --request-values-json $REQ_VALUES \ + --token-output-file /tmp/token \ + --refresh Usage: https-wrench jwtinfo [flags] @@ -243,12 +267,13 @@ Usage: Flags: -h, --help help for jwtinfo --refresh Run in foreground and automatically refresh the token - --renew-threshold float Token renewal threshold as a percentage of lifetime (default 80) + --renew-threshold float Percentage of token lifetime to wait before refreshing (default 80) --request-url string HTTP address to use for the JWT token request + --request-values string Key-value pairs to use for the JWT token request (e.g., key=value) --request-values-file string File containing the JSON encoded values to use for the JWT token request --request-values-json string JSON encoded values to use for the JWT token request --token-file string File containing the JWT token - --token-output-file string File where the acquired/refreshed token will be written + --token-output-file string File to write the refreshed token to --validation-url string Url of the JSON Web Key Set (JWKS) to use for validating the JWT token Global Flags: @@ -267,13 +292,30 @@ Decode a token from a file: Request a token and save it to a file: ```shell -❯ https-wrench jwtinfo --request-url https://auth.example.com/token --request-values-json '{"client_id":"foo"}' --token-output-file ./token.jwt +❯ https-wrench jwtinfo \ + --request-url https://auth.example.com/token \ + --request-values-json '{"client_id":"foo"}' \ + --token-output-file ./token.jwt +``` + +Request a token using key-value pairs: + +```shell +❯ https-wrench jwtinfo \ + --request-url https://auth.example.com/token \ + --request-values client_id=foo \ + --request-values client_secret=bar ``` Request a token, save it to a file, and keep it refreshed until interrupted: ```shell -❯ https-wrench jwtinfo --request-url https://auth.example.com/token --request-values-json '{"client_id":"foo"}' --token-output-file ./token.jwt --refresh --renew-threshold 90 +❯ https-wrench jwtinfo \ + --request-url https://auth.example.com/token \ + --request-values-json '{"client_id":"foo"}' \ + --token-output-file ./token.jwt \ + --renew-threshold 90 \ + --refresh ``` ### HTTPS Wrench jwks diff --git a/devenv.lock b/devenv.lock index 05ef8a8..086b6b1 100644 --- a/devenv.lock +++ b/devenv.lock @@ -3,11 +3,11 @@ "devenv": { "locked": { "dir": "src/modules", - "lastModified": 1777299001, - "narHash": "sha256-r1tFf3mRY5/Fh5DskQLiXjb4AUnM+tOA3pNyrLkXNfA=", + "lastModified": 1777837414, + "narHash": "sha256-L7g797htlkWyFW+6Y4qibuyaVMcDaVjdBTcaPMbKPmY=", "owner": "cachix", "repo": "devenv", - "rev": "cbcbe22f0990293d0b540fbc7703b1361cbce060", + "rev": "9708ea1ebc52d6189cff09b837067daefb0bf0e7", "type": "github" }, "original": { @@ -109,11 +109,11 @@ }, "nixpkgs-stable": { "locked": { - "lastModified": 1777077449, - "narHash": "sha256-AIiMJiqvGrN4HyLEbKAoCSRRYn0rnlW5VbKNIMIYqm4=", + "lastModified": 1777673416, + "narHash": "sha256-5c2POKPOjU40Kh0MirOdScBLG0bu9TAuPYAtPRNZMBs=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "a4bf06618f0b5ee50f14ed8f0da77d34ecc19160", + "rev": "26ef669cffa904b6f6832ab57b77892a37c1a671", "type": "github" }, "original": { diff --git a/devenv.nix b/devenv.nix index ddd9481..e92bb83 100644 --- a/devenv.nix +++ b/devenv.nix @@ -659,6 +659,21 @@ in { ./dist/https-wrench jwtinfo --request-url "$REQ_URL" --request-values-file ~/.config/https-wrench/jwtinfo_test_keycloak_req_values.json --validation-url "$VALIDATION_URL" ''; + scripts.run-jwtinfo-test-keycloak-mixed-value-flags.exec = '' + gum format "### JwtInfo request against priv Keycloak with mixed values flags" + + REQ_URL="https://keycloak.k3s.os76.xyz/realms/os76/protocol/openid-connect/token" + VALIDATION_URL="https://keycloak.k3s.os76.xyz/realms/os76/protocol/openid-connect/certs" + + ./dist/https-wrench jwtinfo --request-url "$REQ_URL" \ + --request-values client_id=istio-test-01 \ + --request-values-file ~/.config/https-wrench/jwtinfo_test_keycloak_req_values_pw_only.json \ + --request-values username=xeno \ + --request-values scope=openid \ + --request-values grant_type=password \ + --validation-url "$VALIDATION_URL" + ''; + scripts.run-go-tests.exec = '' gum format "## Run GO tests" diff --git a/internal/cmd/jwtinfo.go b/internal/cmd/jwtinfo.go index fe61b00..a2c7488 100644 --- a/internal/cmd/jwtinfo.go +++ b/internal/cmd/jwtinfo.go @@ -6,6 +6,7 @@ package cmd import ( "context" + "fmt" "io" "net/http" "os" @@ -15,9 +16,11 @@ import ( "github.com/MicahParks/keyfunc/v3" "github.com/spf13/cobra" "github.com/xenos76/https-wrench/internal/jwtinfo" + "github.com/xenos76/https-wrench/internal/style" ) var ( + flagNameRequestValues = "request-values" flagNameRequestJSONValues = "request-values-json" flagNameRequestValuesFile = "request-values-file" flagNameRequestURL = "request-url" @@ -26,8 +29,6 @@ var ( flagNameRefresh = "refresh" flagNameTokenOutputFile = "token-output-file" flagNameRenewThreshold = "renew-threshold" - requestJSONValues string - requestValuesFile string requestURL string tokenFile string jwksURL string @@ -35,8 +36,32 @@ var ( tokenOutputFile string renewThreshold float64 keyfuncDefOverride keyfunc.Override + + // requestSteps tracks the sequence of request-related flags as they appear on the command line. + requestSteps []requestValueStep ) +// requestValueStep represents a single occurrence of a request flag and its value. +type requestValueStep struct { + kind string // "json", "file", or "kv" + value string +} + +// stepFlag implements the pflag.Value interface to capture the order of flag occurrences. +type stepFlag struct { + kind string +} + +func (*stepFlag) String() string { return "" } + +// Set appends the flag's value and its type to the global requestSteps slice. +func (f *stepFlag) Set(s string) error { + requestSteps = append(requestSteps, requestValueStep{kind: f.kind, value: s}) + return nil +} + +func (*stepFlag) Type() string { return "string" } + var jwtinfoCmd = &cobra.Command{ Use: "jwtinfo", Short: "Inspect and validate JSON Web Tokens (JWT)", @@ -51,16 +76,34 @@ Examples: https-wrench jwtinfo --token-file /var/run/secrets/kubernetes.io/serviceaccount/token # Request a JWT token using inline values - https-wrench jwtinfo --request-url $REQ_URL --request-values-json $REQ_VALUES + https-wrench jwtinfo \ + --request-url $REQ_URL \ + --request-values-json $REQ_VALUES # Request a JWT token using values file - https-wrench jwtinfo --request-url $REQ_URL --request-values-file request-values.json + https-wrench jwtinfo \ + --request-url $REQ_URL \ + --request-values-file request-values.json + + # Request a JWT token using request-values flag + https-wrench jwtinfo \ + --request-url $REQ_URL \ + --request-values username=test \ + --request-values password=test \ + --request-values scope=login # Request and validate a JWT token - https-wrench jwtinfo --request-url $REQ_URL --request-values-json $REQ_VALUES --validation-url $VALIDATION_URL + https-wrench jwtinfo \ + --request-url $REQ_URL \ + --request-values-json $REQ_VALUES \ + --validation-url $VALIDATION_URL # Request a JWT token, write it to a file and refresh it before expiration - https-wrench jwtinfo --request-url $REQ_URL --request-values-json $REQ_VALUES --token-output-file /tmp/token --refresh + https-wrench jwtinfo \ + --request-url $REQ_URL \ + --request-values-json $REQ_VALUES \ + --token-output-file /tmp/token \ + --refresh `, Run: func(cmd *cobra.Command, _ []string) { var ( @@ -70,7 +113,11 @@ Examples: requestValuesMap = make(map[string]string) ) - // TODO: remove global --config option + if refresh && requestURL == "" { + fmt.Fprintln(cmd.OutOrStdout(), style.LgSprintf(style.Error, "Error: --refresh requires --request-url")) + return + } + if tokenFile != "" { tokenData, err = jwtinfo.ReadTokenFromFile(tokenFile) if err != nil { @@ -84,32 +131,29 @@ Examples: } if requestURL != "" { - if requestValuesFile != "" { - requestValuesMap, err = jwtinfo.ReadRequestValuesFile( - requestValuesFile, - requestValuesMap, - ) - if err != nil { - cmd.Printf( - "error while reading request's values from file: %s", - err, + for _, step := range requestSteps { + switch step.kind { + case "json": + requestValuesMap, err = jwtinfo.ParseRequestJSONValues( + step.value, + requestValuesMap, ) - - return + case "file": + requestValuesMap, err = jwtinfo.ReadRequestValuesFile( + step.value, + requestValuesMap, + ) + case "kv": + requestValuesMap, err = jwtinfo.ParseKVValue( + step.value, + requestValuesMap, + ) + default: + continue } - } - if requestJSONValues != "" { - requestValuesMap, err = jwtinfo.ParseRequestJSONValues( - requestJSONValues, - requestValuesMap, - ) if err != nil { - cmd.Printf( - "error while parsing request's values JSON string: %s", - err, - ) - + cmd.Printf("error processing %s: %s\n", step.kind, err) return } } @@ -153,11 +197,6 @@ Examples: } if refresh { - if requestURL == "" { - cmd.Printf("Error: --refresh requires --request-url\n") - return - } - // Setup graceful shutdown ctx, cancel := context.WithCancel(cmd.Context()) defer cancel() @@ -211,20 +250,24 @@ func init() { "HTTP address to use for the JWT token request", ) - jwtinfoCmd.Flags().StringVar( - &requestJSONValues, + jwtinfoCmd.Flags().Var( + &stepFlag{kind: "json"}, flagNameRequestJSONValues, - "", "JSON encoded values to use for the JWT token request", ) - jwtinfoCmd.Flags().StringVar( - &requestValuesFile, + jwtinfoCmd.Flags().Var( + &stepFlag{kind: "file"}, flagNameRequestValuesFile, - "", "File containing the JSON encoded values to use for the JWT token request", ) + jwtinfoCmd.Flags().Var( + &stepFlag{kind: "kv"}, + flagNameRequestValues, + "Key-value pairs to use for the JWT token request (e.g., key=value)", + ) + jwtinfoCmd.Flags().StringVar( &jwksURL, flagNameJwksURL, diff --git a/internal/cmd/jwtinfo_test.go b/internal/cmd/jwtinfo_test.go index 73d3bbf..51fe0dc 100644 --- a/internal/cmd/jwtinfo_test.go +++ b/internal/cmd/jwtinfo_test.go @@ -2,57 +2,100 @@ package cmd import ( "bytes" + "context" + "net/http" + "net/http/httptest" "testing" + "github.com/spf13/pflag" "github.com/stretchr/testify/require" ) -func TestJwtinfoCmd(t *testing.T) { +func TestJwtinfoCmd_Errors(t *testing.T) { tests := []struct { - name string - args []string - expectError bool - errMsgs []string - expected []string + name string + setup func() + expected []string }{ { - name: "invalid file", - args: []string{"jwtinfo", "--token-file", "non_existent.jwt"}, - expectError: false, - expected: []string{"error while reading token value from file"}, + name: "invalid file", + setup: func() { + tokenFile = "non_existent.jwt" + }, + expected: []string{"error while reading token value from file"}, + }, + { + name: "refresh without request url", + setup: func() { + tokenFile = "some.jwt" + refresh = true + requestURL = "" + }, + expected: []string{"Error: --refresh requires --request-url"}, }, } - for _, tc := range tests { - tt := tc + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Cleanup(func() { - rootCmd.Flags().Set("version", "false") - jwtinfoCmd.Flags().Set("token-file", "") - jwtinfoCmd.Flags().Set("clipboard", "false") - }) - - reqOut := new(bytes.Buffer) - reqCmd := rootCmd - reqCmd.SetOut(reqOut) - reqCmd.SetErr(reqOut) - reqCmd.SetArgs(tt.args) - err := reqCmd.Execute() - - if tt.expectError { - require.Error(t, err) - - for _, expected := range tt.errMsgs { - require.ErrorContains(t, err, expected) - } - } else { - require.NoError(t, err) - } + resetFlags() + tt.setup() + + out := new(bytes.Buffer) + jwtinfoCmd.SetOut(out) + jwtinfoCmd.SetErr(out) + jwtinfoCmd.SetContext(context.Background()) - got := reqOut.String() + jwtinfoCmd.Run(jwtinfoCmd, nil) + + got := out.String() for _, expected := range tt.expected { require.Contains(t, got, expected) } }) } } + +func TestJwtinfoCmd_Success(t *testing.T) { + // Mock Token Server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // JWT Payload: {"sub":"1234567890","name":"John Doe","iat":1516239022,"exp":1516249022} + token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2M" + + "jM5MDIyLCJleHAiOjE1MTYyNDkwMjJ9.c2lnbmF0dXJl" + w.Write([]byte(`{"access_token": "` + token + `"}`)) + })) + defer ts.Close() + + resetFlags() + + requestURL = ts.URL + requestSteps = []requestValueStep{{kind: "kv", value: "key=val"}} + + out := new(bytes.Buffer) + jwtinfoCmd.SetOut(out) + jwtinfoCmd.SetErr(out) + jwtinfoCmd.SetContext(context.Background()) + + jwtinfoCmd.Run(jwtinfoCmd, nil) + + got := out.String() + require.Contains(t, got, "\"sub\"") + require.Contains(t, got, "\"1234567890\"") +} + +func resetFlags() { + jwtinfoCmd.Flags().VisitAll(func(f *pflag.Flag) { + f.Changed = false + f.Value.Set(f.DefValue) + }) + + requestSteps = nil + tokenFile = "" + requestURL = "" + refresh = false + jwksURL = "" + tokenOutputFile = "" + renewThreshold = 80.0 +} diff --git a/internal/jwtinfo/jwtinfo.go b/internal/jwtinfo/jwtinfo.go index 30d6bf2..41cf0a9 100644 --- a/internal/jwtinfo/jwtinfo.go +++ b/internal/jwtinfo/jwtinfo.go @@ -708,3 +708,36 @@ func (jtd *JwtTokenData) WriteTokenToFile(outFileName string, outWriter io.Write ts := time.Now().Format(time.RFC3339) fmt.Fprintf(outWriter, "[%s] Token persisted to %s\n", ts, outFileName) } + +// ParseKVValue parses a string in the format "key=value" and adds it to the provided map. +// It returns an error if the format is invalid or the string is empty. +func ParseKVValue( + kv string, + reqValuesMap map[string]string, +) ( + map[string]string, + error, +) { + if kv == "" { + return nil, errors.New("empty string provided as key-value pair") + } + + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid key-value pair: %s (expected key=value)", kv) + } + + key := strings.TrimSpace(parts[0]) + if key == "" { + return nil, fmt.Errorf("empty request parameter name in: %s", kv) + } + + newMap := maps.Clone(reqValuesMap) + if newMap == nil { + newMap = make(map[string]string) + } + + newMap[key] = parts[1] + + return newMap, nil +} diff --git a/internal/jwtinfo/jwtinfo_test.go b/internal/jwtinfo/jwtinfo_test.go index f4998f7..edbe77c 100644 --- a/internal/jwtinfo/jwtinfo_test.go +++ b/internal/jwtinfo/jwtinfo_test.go @@ -1010,3 +1010,48 @@ func TestPrintTokenInfo_Errors(t *testing.T) { require.ErrorContains(t, err, "unable to unmarshal time claims from AccessToken") }) } + +func TestParseKVValue(t *testing.T) { + t.Run("Success", func(t *testing.T) { + t.Parallel() + + inputMap := map[string]string{"existing": "value"} + outputMap, err := ParseKVValue("key=val", inputMap) + require.NoError(t, err) + require.Equal(t, "val", outputMap["key"]) + require.Equal(t, "value", outputMap["existing"]) + }) + + t.Run("Overwrite", func(t *testing.T) { + t.Parallel() + + inputMap := map[string]string{"key": "old"} + outputMap, err := ParseKVValue("key=new", inputMap) + require.NoError(t, err) + require.Equal(t, "new", outputMap["key"]) + }) + + t.Run("Error_NoEqual", func(t *testing.T) { + t.Parallel() + + _, err := ParseKVValue("invalid", nil) + require.Error(t, err) + require.ErrorContains(t, err, "expected key=value") + }) + + t.Run("Error_Empty", func(t *testing.T) { + t.Parallel() + + _, err := ParseKVValue("", nil) + require.Error(t, err) + require.ErrorContains(t, err, "empty string provided") + }) + + t.Run("Error_EmptyKey", func(t *testing.T) { + t.Parallel() + + _, err := ParseKVValue("=value", nil) + require.Error(t, err) + require.ErrorContains(t, err, "empty request parameter name") + }) +}