From 028b5fd5a82b8b9bd1530d794700fc82f131f1a9 Mon Sep 17 00:00:00 2001 From: David Henning Date: Sat, 25 Jul 2026 21:22:47 +0200 Subject: [PATCH] feat: opt-in claim validation with --verify-claims, --aud, --iss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add explicit, opt-in RFC 7519 claim validation that can fail the exit code, without changing the default behavior. --verify-claims enforces the temporal claims (exp, nbf); --aud and --iss additionally require a specific audience or issuer and imply validation. The result prints as a Claims: VALID / INVALID section and is reported as claimsValid under --json. Validation is deliberately independent of signature verification: it runs with or without --key, shares the display clock (jwt.WithTimeFunc(timeNow)) so verdicts agree with the expired / not-yet-valid annotations, and uses go-jwt's validator rather than reimplementing the checks. When both a key and claim flags are given, both sections are shown and the command exits nonzero if either fails, with the signature verdict taking precedence for the returned sentinel. A missing exp is not treated as expired, and claim flags on a JWE emit a note and are skipped. The pre-existing invariant is preserved: a bare decode never fails on expiry — nothing validates claims unless a claim flag is passed. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 18 ++- README.md | 19 ++- claims.go | 86 ++++++++++++++ claims_test.go | 303 ++++++++++++++++++++++++++++++++++++++++++++++++ jsonout.go | 19 ++- jsonout_test.go | 4 +- main.go | 53 +++++++-- 7 files changed, 483 insertions(+), 19 deletions(-) create mode 100644 claims.go create mode 100644 claims_test.go diff --git a/AGENTS.md b/AGENTS.md index 312473f..036ddc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,16 +2,16 @@ ## Project Overview -jwtd is a CLI tool written in Go that decodes and pretty-prints JSON Web Tokens (JWTs) and JSON Web Encryption (JWE) tokens with syntax-highlighted JSON output. It can also verify JWS signatures and decrypt JWEs when given a key via `--key`/`-k` or the `JWTD_KEY` environment variable. `--json` emits a machine-readable object instead of the colored sections, and `--color=auto|always|never` overrides TTY-based color detection. +jwtd is a CLI tool written in Go that decodes and pretty-prints JSON Web Tokens (JWTs) and JSON Web Encryption (JWE) tokens with syntax-highlighted JSON output. It can also verify JWS signatures and decrypt JWEs when given a key via `--key`/`-k` or the `JWTD_KEY` environment variable. `--verify-claims` (plus `--aud`/`--iss`) opts into RFC 7519 claim validation with a nonzero exit on failure, independent of the signature check. `--json` emits a machine-readable object instead of the colored sections, and `--color=auto|always|never` overrides TTY-based color detection. ## Architecture -All functionality lives in package `main`, split across five source files: +All functionality lives in package `main`, split across six source files: ### `main.go` - CLI, token input, and the JWT/JWS path - `main()` / `newRootCommand()` - Build and execute the Cobra root command with the `--key`/`-k`, `--json`, and `--color` flags; suppress Cobra's automatic usage/error output so runtime errors are rendered once, while invalid-signature details are not duplicated -- `run()` / `readToken()` - Resolves the token from arguments, stdin pipe, or interactive readline prompt; falls back to `JWTD_KEY` when `--key` is not set; applies the color mode, then dispatches to the JWT/JWE handler or, under `--json`, to the JSON handler +- `run()` / `readToken()` / `decodeJWTHuman()` - Resolves the token from arguments, stdin pipe, or interactive readline prompt; falls back to `JWTD_KEY` when `--key` is not set; applies the color mode, then dispatches to the JWT/JWE handler or, under `--json`, to the JSON handler. `decodeJWTHuman` wraps `decodeAndPrint` and, when claim validation was requested, prints a Claims section after it; both the signature and claim checks run so their sections show together, and the command exits nonzero if either fails (the signature verdict takes precedence for the returned sentinel). Claim flags on a JWE emit a stderr note and are otherwise skipped - `applyColorMode()` - Maps `--color` onto `fatih/color`'s global `NoColor`: `auto` leaves TTY/`NO_COLOR` detection untouched, `always` forces color, `never` disables it; `--json` always forces color off - `headerKID()` - Extracts the token's `kid` header (or `""`) so JWK Set verification/decryption selects the key the token names - `printKeyInterpretation()` - Notes on stderr how a key argument was read when it was not read as a file, so precedence-based detection cannot silently take a value the user meant one way and use it another; adds the process-list exposure warning for `--key` values, which `JWTD_KEY` does not carry (`/proc//cmdline` is world-readable, `/proc//environ` is owner-only). Diagnostics go to stderr so stdout stays parseable @@ -26,6 +26,14 @@ All functionality lives in package `main`, split across five source files: - `decodeJWTJSON()` / `decodeJWEJSON()` - Emit one JSON object per token. A JWT carries `header`, `payload`, `signature`, and (with a key) `signatureValid`; an invalid signature still writes the JSON and then returns `errInvalidSignature` for the exit code. A JWE carries `protectedHeader` plus either encrypted part sizes (no key) or `decryptedPayload` (with a key) - `jsonPayloadValue()` / `base64URLLen()` / `writeJSON()` - Decode a decrypted payload as structured JSON when possible (else a string); report part sizes; and encode with `encoding/json`, which preserves `json.Number` exactly and escapes control characters including ESC. Timestamps are left as raw numeric claim values here — `formatTimestamps` is intentionally not applied — so consumers do their own date math +### `claims.go` - Opt-in claim validation + +- `claimChecks` / `requested()` - Holds the `--verify-claims`, `--aud`, and `--iss` flag values. The zero value requests nothing, so the default stays decode-only and the exit code keeps reflecting the signature alone; `requested()` treats an expected audience or issuer as implying validation, so those flags work without also passing `--verify-claims` +- `validateClaimsSet()` - Runs the requested RFC 7519 checks against the already-parsed claims via `jwt.NewValidator` with `jwt.WithTimeFunc(timeNow)` (so the verdict shares the display clock and is deterministic under `pinTime`), returning `valid` plus a reason. Temporal claims (`exp`, `nbf`) that are present are always checked; an expected `aud`/`iss` is additionally required to be present and match. A missing `exp` is not treated as expired. No signature verification happens here — the human and `--json` paths share this core +- `verifyClaims()` / `claimReason()` - `verifyClaims` parses the token, calls the core, and renders `Claims: VALID`/`INVALID` with the reason, returning the `errInvalidClaims` sentinel on failure (and a hard error on an unparseable token). `claimReason` flattens the validator's newline-joined multi-error onto one `; `-separated line so the dim reason and wrapped error stay readable + +**Claim validation is display-and-exit-code only, and deliberately separate from signature verification.** It performs no cryptography and runs whether or not a key is given, so `--verify-claims` on an unverified token still reports expiry — the two verdicts (`Signature:` and `Claims:`) are shown independently and either failing exits nonzero. This keeps the pre-existing invariant that a bare decode never fails on expiry intact: nothing validates claims unless a claim flag is passed. + ### `jwe.go` - JWE parsing and decryption - `isJWE()` - Detects JWE compact serialization (5 dot-separated parts vs. 3 for a JWT) @@ -132,8 +140,8 @@ JWTD_KEY=key.pem jwtd # same, via environment variable ## Conventions -- **Single package.** All code stays in package `main`, split across topical files (`main.go`, `jwe.go`, `keys.go`, `output.go`, `jsonout.go`). -- **Tests mirror the source files:** `main_test.go`, `jwe_test.go`, `keys_test.go`, `output_test.go`, `jsonout_test.go`, with shared fixtures (key generation, token signing/encryption helpers) in `helpers_test.go` and GoReleaser/release-workflow invariants in `workflow_test.go`. Use table-driven tests where multiple cases share the same structure. +- **Single package.** All code stays in package `main`, split across topical files (`main.go`, `jwe.go`, `keys.go`, `output.go`, `jsonout.go`, `claims.go`). +- **Tests mirror the source files:** `main_test.go`, `jwe_test.go`, `keys_test.go`, `output_test.go`, `jsonout_test.go`, `claims_test.go`, with shared fixtures (key generation, token signing/encryption helpers) in `helpers_test.go` and GoReleaser/release-workflow invariants in `workflow_test.go`. Use table-driven tests where multiple cases share the same structure. - **Color scheme** is configured in `newFormatter()` via `go-prettyjson` and `fatih/color`. Colors auto-disable when stdout is not a TTY. - **Error handling:** Return errors up the call stack with `fmt.Errorf` wrapping (`%w`). The root command suppresses Cobra's automatic error and usage output; `main()` renders non-signature errors and exits nonzero, while invalid signatures print their own details and return `errInvalidSignature`. - **Formatting:** Use `gofmt`/`goimports` standard formatting. No special linter configuration. diff --git a/README.md b/README.md index 9e5788d..42f9335 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A CLI tool that decodes and pretty-prints JSON Web Tokens (JWTs) and JSON Web En - JWK Set key selection by the token's `kid` header (falls back to the first key when the token has none) - Supports both private and public keys (private keys are auto-converted for verification) - Invalid signatures produce a nonzero exit status when `--key`/`JWTD_KEY` is used +- Opt-in claim validation with `--verify-claims` (exp/nbf) plus `--aud`/`--iss` assertions, exiting nonzero when a check fails — independent of the signature check - Nested token detection: JWT-inside-JWE and JWE-inside-JWE are decoded recursively - `JWTD_KEY` environment variable for default key configuration - Syntax-highlighted JSON output with a consistent color scheme @@ -172,6 +173,22 @@ jwtd --key /path/to/public-key.pem eyJhbGciOiJSUzI1NiIs... An invalid signature prints `Signature: INVALID` and exits with a nonzero status. Claim validity, including expiry, is not part of this cryptographic signature check. +### Validate claims + +By default, claim validity never affects the exit code — expiry is shown only as a display annotation. Opt in to enforcement with `--verify-claims`, which validates the temporal claims (`exp`, `nbf`) and exits nonzero when the token is expired or not yet valid: + +```sh +jwtd --verify-claims eyJhbGciOiJIUzI1NiIs... +``` + +Add `--aud` and/or `--iss` to also require a specific audience or issuer; either flag implies claim validation, so the temporal checks run too: + +```sh +jwtd --aud my-api --iss https://issuer.example eyJhbGciOiJIUzI1NiIs... +``` + +The result is printed as a `Claims: VALID` / `Claims: INVALID` section (with the reason), and reported as `claimsValid` under `--json`. Claim validation is independent of the signature: it runs with or without `--key`, and when both are used the command exits nonzero if either check fails. A token with no `exp` is not treated as expired. The clock matches the displayed `expired` / `not yet valid` annotations, with no leeway. Claim validation applies to JWTs only; it is skipped (with a note) for JWEs. + ### Key formats The `--key` flag accepts: @@ -207,7 +224,7 @@ jwtd --json eyJhbGciOiJIUzI1NiIs... jwtd --json --key key.pem eyJhbGciOiJSUzI1NiIs... | jq .signatureValid ``` -A JWT is emitted as `{ "header", "payload", "signature" }`, plus `"signatureValid"` when a key is provided. Timestamps stay as their raw numeric claim values (no RFC3339 conversion) so consumers can do their own date math, and numbers are preserved exactly. A JWE is emitted as `{ "protectedHeader", ... }` with either the encrypted part sizes (no key) or the decrypted payload (with a key). An invalid signature still prints the JSON and then exits nonzero. +A JWT is emitted as `{ "header", "payload", "signature" }`, plus `"signatureValid"` when a key is provided and `"claimsValid"` when claim validation is requested. Timestamps stay as their raw numeric claim values (no RFC3339 conversion) so consumers can do their own date math, and numbers are preserved exactly. A JWE is emitted as `{ "protectedHeader", ... }` with either the encrypted part sizes (no key) or the decrypted payload (with a key). An invalid signature still prints the JSON and then exits nonzero. ### Color diff --git a/claims.go b/claims.go new file mode 100644 index 0000000..1e8fabc --- /dev/null +++ b/claims.go @@ -0,0 +1,86 @@ +package main + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/fatih/color" + "github.com/golang-jwt/jwt/v5" +) + +var errInvalidClaims = errors.New("invalid claims") + +// claimChecks describes the opt-in claim validations requested via flags. The +// zero value requests nothing, so the default behavior stays decode-only and +// the exit code keeps reflecting the signature alone. +type claimChecks struct { + verify bool // --verify-claims: enforce the temporal claims (exp, nbf) + audience string // --aud: require this audience in the aud claim + issuer string // --iss: require this issuer in the iss claim +} + +// requested reports whether any claim validation was asked for. An expected +// audience or issuer implies validation, so those flags work without also +// passing --verify-claims. +func (c claimChecks) requested() bool { + return c.verify || c.audience != "" || c.issuer != "" +} + +// validateClaimsSet runs the requested RFC 7519 claim validations against the +// already-parsed claims without printing, so the human and --json paths share +// one implementation. It reports valid=true on success, or valid=false with a +// human-readable reason. +// +// Validation always covers the temporal claims (exp, nbf) that are present; an +// expected audience or issuer is additionally required to be present and to +// match. The clock is the shared timeNow, so the verdict agrees with the +// displayed expired / not-yet-valid annotations and is deterministic under +// test. This is purely a claims check and performs no signature verification. +func validateClaimsSet(claims jwt.MapClaims, c claimChecks) (bool, error) { + opts := []jwt.ParserOption{jwt.WithTimeFunc(timeNow)} + if c.audience != "" { + opts = append(opts, jwt.WithAudience(c.audience)) + } + if c.issuer != "" { + opts = append(opts, jwt.WithIssuer(c.issuer)) + } + if err := jwt.NewValidator(opts...).Validate(claims); err != nil { + return false, err + } + return true, nil +} + +// verifyClaims parses the token, runs the requested claim validations, and +// prints "Claims: VALID" or "Claims: INVALID" with the reason. It returns the +// errInvalidClaims sentinel (wrapping the reason) when a check fails so the CLI +// exits nonzero, mirroring signature verification; an unparseable token returns +// a hard error instead. Claim validation is independent of the signature: it +// runs with or without a key. +func verifyClaims(w io.Writer, tokenStr string, c claimChecks) error { + _, _, claims, err := parseUnverifiedJWT(tokenStr) + if err != nil { + return err + } + + valid, reason := validateClaimsSet(claims, c) + if !valid { + if _, werr := color.New(color.FgRed, color.Bold).Fprintln(w, "Claims: INVALID"); werr != nil { + return werr + } + if _, werr := dimColor.Fprintf(w, " %s\n", claimReason(reason)); werr != nil { + return werr + } + return fmt.Errorf("%w: %s", errInvalidClaims, claimReason(reason)) + } + _, werr := color.New(color.FgGreen, color.Bold).Fprintln(w, "Claims: VALID") + return werr +} + +// claimReason flattens a validation error onto a single line. The jwt validator +// joins multiple failures with newlines (via errors.Join); collapsing them to a +// "; "-separated line keeps the dim reason and the wrapped error readable. +func claimReason(err error) string { + return strings.ReplaceAll(err.Error(), "\n", "; ") +} diff --git a/claims_test.go b/claims_test.go new file mode 100644 index 0000000..34d8dd1 --- /dev/null +++ b/claims_test.go @@ -0,0 +1,303 @@ +package main + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/golang-jwt/jwt/v5" +) + +func TestClaimChecks_Requested(t *testing.T) { + tests := []struct { + name string + c claimChecks + want bool + }{ + {"zero value", claimChecks{}, false}, + {"verify", claimChecks{verify: true}, true}, + {"audience", claimChecks{audience: "api"}, true}, + {"issuer", claimChecks{issuer: "iss"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.c.requested(); got != tt.want { + t.Errorf("requested() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestValidateClaimsSet(t *testing.T) { + // now = 1000: exp>1000 is live, nbf<=1000 is active. + pinTime(t, 1000) + tests := []struct { + name string + claims jwt.MapClaims + checks claimChecks + wantValid bool + wantReason []string // substrings the reason must contain when invalid + }{ + { + name: "live temporal window", + claims: jwt.MapClaims{"exp": float64(2000), "nbf": float64(500)}, + checks: claimChecks{verify: true}, + wantValid: true, + }, + { + name: "expired", + claims: jwt.MapClaims{"exp": float64(500)}, + checks: claimChecks{verify: true}, + wantValid: false, + wantReason: []string{"expired"}, + }, + { + name: "not yet valid", + claims: jwt.MapClaims{"nbf": float64(1500)}, + checks: claimChecks{verify: true}, + wantValid: false, + wantReason: []string{"not valid yet"}, + }, + { + name: "no temporal claims present", + claims: jwt.MapClaims{"sub": "a"}, + checks: claimChecks{verify: true}, + wantValid: true, + }, + { + name: "audience match", + claims: jwt.MapClaims{"aud": "my-api"}, + checks: claimChecks{audience: "my-api"}, + wantValid: true, + }, + { + name: "audience mismatch", + claims: jwt.MapClaims{"aud": "other"}, + checks: claimChecks{audience: "my-api"}, + wantValid: false, + wantReason: []string{"aud"}, + }, + { + name: "audience required but missing", + claims: jwt.MapClaims{"sub": "a"}, + checks: claimChecks{audience: "my-api"}, + wantValid: false, + wantReason: []string{"aud"}, + }, + { + name: "issuer match", + claims: jwt.MapClaims{"iss": "https://issuer.example"}, + checks: claimChecks{issuer: "https://issuer.example"}, + wantValid: true, + }, + { + name: "issuer mismatch", + claims: jwt.MapClaims{"iss": "https://evil.example"}, + checks: claimChecks{issuer: "https://issuer.example"}, + wantValid: false, + wantReason: []string{"issuer"}, + }, + { + name: "multiple failures joined", + claims: jwt.MapClaims{"exp": float64(500), "aud": "other"}, + checks: claimChecks{verify: true, audience: "my-api"}, + wantValid: false, + wantReason: []string{"expired", "aud"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + valid, reason := validateClaimsSet(tt.claims, tt.checks) + if valid != tt.wantValid { + t.Fatalf("valid = %v (reason %v), want %v", valid, reason, tt.wantValid) + } + if tt.wantValid { + if reason != nil { + t.Errorf("expected nil reason on valid, got %v", reason) + } + return + } + for _, want := range tt.wantReason { + if !strings.Contains(reason.Error(), want) { + t.Errorf("reason %q missing %q", reason.Error(), want) + } + } + }) + } +} + +func TestVerifyClaims_ValidPrintsAndReturnsNil(t *testing.T) { + pinTime(t, 1000) + token := signHS256(t, []byte("secret"), jwt.MapClaims{"exp": float64(2000)}) + + var buf bytes.Buffer + if err := verifyClaims(&buf, token, claimChecks{verify: true}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stripANSI(buf.String()), "Claims: VALID") { + t.Errorf("expected Claims: VALID, got %q", buf.String()) + } +} + +func TestVerifyClaims_InvalidReturnsSentinelAndReason(t *testing.T) { + pinTime(t, 1000) + token := signHS256(t, []byte("secret"), jwt.MapClaims{"exp": float64(500)}) + + var buf bytes.Buffer + err := verifyClaims(&buf, token, claimChecks{verify: true}) + if !errors.Is(err, errInvalidClaims) { + t.Fatalf("expected errInvalidClaims, got %v", err) + } + out := stripANSI(buf.String()) + if !strings.Contains(out, "Claims: INVALID") || !strings.Contains(out, "expired") { + t.Errorf("expected INVALID with reason, got %q", out) + } +} + +func TestVerifyClaims_UnparseableTokenIsHardError(t *testing.T) { + var buf bytes.Buffer + err := verifyClaims(&buf, "not.a.jwt", claimChecks{verify: true}) + if err == nil { + t.Fatal("expected an error for an unparseable token") + } + if errors.Is(err, errInvalidClaims) { + t.Errorf("a parse failure must be a hard error, not errInvalidClaims: %v", err) + } + if buf.Len() != 0 { + t.Errorf("nothing should be printed for an unparseable token, got %q", buf.String()) + } +} + +func TestClaimReason_FlattensJoinedErrors(t *testing.T) { + joined := errors.Join(errors.New("token is expired"), errors.New("token has invalid audience")) + got := claimReason(joined) + if strings.Contains(got, "\n") { + t.Errorf("reason should be a single line, got %q", got) + } + if got != "token is expired; token has invalid audience" { + t.Errorf("unexpected flattened reason %q", got) + } +} + +// --- decodeJWTHuman integration --------------------------------------------- + +func TestDecodeJWTHuman_NoClaimSectionWhenNotRequested(t *testing.T) { + pinTime(t, 1000) + token := signHS256(t, []byte("secret"), jwt.MapClaims{"exp": float64(500)}) + + var buf bytes.Buffer + if err := decodeJWTHuman(&buf, token, "", claimChecks{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(buf.String(), "Claims:") { + t.Errorf("no Claims section expected without claim flags, got %q", buf.String()) + } +} + +func TestDecodeJWTHuman_ClaimsSectionAfterSignature(t *testing.T) { + pinTime(t, 1000) + token := signHS256(t, []byte("secret"), jwt.MapClaims{"exp": float64(2000)}) + + var buf bytes.Buffer + if err := decodeJWTHuman(&buf, token, "raw:secret", claimChecks{verify: true}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stripANSI(buf.String()) + sigIdx := strings.Index(out, "Signature: VALID") + claimIdx := strings.Index(out, "Claims: VALID") + if sigIdx == -1 || claimIdx == -1 { + t.Fatalf("expected both Signature: VALID and Claims: VALID, got %q", out) + } + if claimIdx < sigIdx { + t.Errorf("Claims section should follow the signature verdict, got %q", out) + } +} + +func TestDecodeJWTHuman_ValidSignatureInvalidClaims(t *testing.T) { + pinTime(t, 1000) + token := signHS256(t, []byte("secret"), jwt.MapClaims{"exp": float64(500)}) + + var buf bytes.Buffer + err := decodeJWTHuman(&buf, token, "raw:secret", claimChecks{verify: true}) + if !errors.Is(err, errInvalidClaims) { + t.Fatalf("expected errInvalidClaims, got %v", err) + } + out := stripANSI(buf.String()) + if !strings.Contains(out, "Signature: VALID") || !strings.Contains(out, "Claims: INVALID") { + t.Errorf("expected valid signature and invalid claims, got %q", out) + } +} + +func TestDecodeJWTHuman_InvalidSignatureStillShowsClaims(t *testing.T) { + pinTime(t, 1000) + token := signHS256(t, []byte("secret"), jwt.MapClaims{"exp": float64(2000)}) + + var buf bytes.Buffer + // Wrong key: the signature is invalid, but the claims are still live. + err := decodeJWTHuman(&buf, token, "raw:wrong-secret", claimChecks{verify: true}) + if !errors.Is(err, errInvalidSignature) { + t.Fatalf("expected errInvalidSignature to take precedence, got %v", err) + } + out := stripANSI(buf.String()) + if !strings.Contains(out, "Signature: INVALID") || !strings.Contains(out, "Claims: VALID") { + t.Errorf("expected both sections shown, got %q", out) + } +} + +// --- decodeJWTJSON claim reporting ------------------------------------------ + +func TestDecodeJWTJSON_ClaimsValidField(t *testing.T) { + pinTime(t, 1000) + + t.Run("valid", func(t *testing.T) { + token := signHS256(t, []byte("secret"), jwt.MapClaims{"exp": float64(2000)}) + var buf bytes.Buffer + if err := decodeJWTJSON(&buf, token, "", claimChecks{verify: true}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(buf.String(), `"claimsValid": true`) { + t.Errorf("expected claimsValid true, got %q", buf.String()) + } + }) + + t.Run("expired emits json then sentinel", func(t *testing.T) { + token := signHS256(t, []byte("secret"), jwt.MapClaims{"exp": float64(500)}) + var buf bytes.Buffer + err := decodeJWTJSON(&buf, token, "", claimChecks{verify: true}) + if !errors.Is(err, errInvalidClaims) { + t.Fatalf("expected errInvalidClaims, got %v", err) + } + if !strings.Contains(buf.String(), `"claimsValid": false`) { + t.Errorf("JSON should still be emitted with claimsValid false, got %q", buf.String()) + } + }) + + t.Run("not requested omits field", func(t *testing.T) { + token := signHS256(t, []byte("secret"), jwt.MapClaims{"exp": float64(2000)}) + var buf bytes.Buffer + if err := decodeJWTJSON(&buf, token, "", claimChecks{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(buf.String(), "claimsValid") { + t.Errorf("claimsValid must be omitted when not requested, got %q", buf.String()) + } + }) +} + +func TestDecodeJWTJSON_SignatureTakesPrecedenceOverClaims(t *testing.T) { + pinTime(t, 1000) + // Expired claims and a wrong key: both checks fail. + token := signHS256(t, []byte("secret"), jwt.MapClaims{"exp": float64(500)}) + + var buf bytes.Buffer + err := decodeJWTJSON(&buf, token, "raw:wrong-secret", claimChecks{verify: true}) + if !errors.Is(err, errInvalidSignature) { + t.Fatalf("expected errInvalidSignature to take precedence, got %v", err) + } + out := buf.String() + if !strings.Contains(out, `"signatureValid": false`) || !strings.Contains(out, `"claimsValid": false`) { + t.Errorf("expected both verdicts false in JSON, got %q", out) + } +} diff --git a/jsonout.go b/jsonout.go index e2863cb..a9c35ab 100644 --- a/jsonout.go +++ b/jsonout.go @@ -19,6 +19,7 @@ type jwtJSON struct { Payload map[string]any `json:"payload"` Signature string `json:"signature"` SignatureValid *bool `json:"signatureValid,omitempty"` + ClaimsValid *bool `json:"claimsValid,omitempty"` } // jweJSON is the machine-readable form of a JWE. Without a key it reports the @@ -38,10 +39,12 @@ type jweEncrypted struct { } // decodeJWTJSON writes a JWT as a single JSON object. When a key is supplied the -// signature is verified and reported in signatureValid; an invalid signature -// still emits the JSON and then returns errInvalidSignature so the exit code -// matches the human path. -func decodeJWTJSON(w io.Writer, tokenStr, keyStr string) error { +// signature is verified and reported in signatureValid; when claim validation is +// requested the verdict is reported in claimsValid. A failing check still emits +// the JSON and then returns a sentinel (errInvalidSignature or errInvalidClaims) +// so the exit code matches the human path; the signature verdict takes +// precedence when both fail, though either way the exit is nonzero. +func decodeJWTJSON(w io.Writer, tokenStr, keyStr string, checks claimChecks) error { token, parts, claims, err := parseUnverifiedJWT(tokenStr) if err != nil { return err @@ -65,6 +68,14 @@ func decodeJWTJSON(w io.Writer, tokenStr, keyStr string) error { } } + if checks.requested() { + valid, reason := validateClaimsSet(claims, checks) + out.ClaimsValid = &valid + if !valid && invalid == nil { + invalid = fmt.Errorf("%w: %s", errInvalidClaims, claimReason(reason)) + } + } + if err := writeJSON(w, out); err != nil { return err } diff --git a/jsonout_test.go b/jsonout_test.go index 1f82172..449b2a9 100644 --- a/jsonout_test.go +++ b/jsonout_test.go @@ -16,7 +16,7 @@ import ( func decodeJWTJSONMap(t *testing.T, token, key string) (map[string]any, error) { t.Helper() var buf bytes.Buffer - err := decodeJWTJSON(&buf, token, key) + err := decodeJWTJSON(&buf, token, key, claimChecks{}) var out map[string]any dec := json.NewDecoder(bytes.NewReader(buf.Bytes())) @@ -152,7 +152,7 @@ func TestDecodeJWTJSON_EscapesTerminalControls(t *testing.T) { token := makeJWT(`{"alg":"HS256"}`, string(payload), "sig") var buf bytes.Buffer - if err := decodeJWTJSON(&buf, token, ""); err != nil { + if err := decodeJWTJSON(&buf, token, "", claimChecks{}); err != nil { t.Fatalf("unexpected error: %v", err) } if bytes.ContainsRune(buf.Bytes(), 0x1b) { diff --git a/main.go b/main.go index d33942d..3cae41e 100644 --- a/main.go +++ b/main.go @@ -46,11 +46,14 @@ func newRootCommand() *cobra.Command { rootCmd.Flags().StringP("key", "k", "", "key for JWE decryption or JWS signature verification: a PEM/DER/JWK file or inline base64, hmac: for a symmetric secret file, or raw: for a literal one (inline values are visible to other local users in the process list, so prefer a file or JWTD_KEY)") rootCmd.Flags().Bool("json", false, "emit machine-readable JSON instead of colorized sections") rootCmd.Flags().String("color", "auto", "colorize output: auto (color only on a TTY), always, or never") + rootCmd.Flags().Bool("verify-claims", false, "validate the temporal claims (exp, nbf) and exit nonzero if the token is expired or not yet valid") + rootCmd.Flags().String("aud", "", "require this audience in the aud claim (implies --verify-claims)") + rootCmd.Flags().String("iss", "", "require this issuer in the iss claim (implies --verify-claims)") return rootCmd } func printExecutionError(w io.Writer, err error) error { - if errors.Is(err, errInvalidSignature) { + if errors.Is(err, errInvalidSignature) || errors.Is(err, errInvalidClaims) { return nil } _, writeErr := fmt.Fprintf(w, "Error: %v\n", err) @@ -78,19 +81,55 @@ func run(cmd *cobra.Command, args []string) error { printKeyInterpretation(cmd.ErrOrStderr(), keyStr, fromFlag) } + verifyClaimsFlag, _ := cmd.Flags().GetBool("verify-claims") + aud, _ := cmd.Flags().GetString("aud") + iss, _ := cmd.Flags().GetString("iss") + checks := claimChecks{verify: verifyClaimsFlag, audience: aud, issuer: iss} + w := cmd.OutOrStdout() - if jsonOut { - if isJWE(token) { + if isJWE(token) { + if checks.requested() { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Note: claim validation (--verify-claims/--aud/--iss) applies to JWTs only and is skipped for JWE.") + } + if jsonOut { return decodeJWEJSON(w, token, keyStr) } - return decodeJWTJSON(w, token, keyStr) + return decodeAndPrintJWE(w, token, keyStr) } - if isJWE(token) { - return decodeAndPrintJWE(w, token, keyStr) + if jsonOut { + return decodeJWTJSON(w, token, keyStr, checks) + } + return decodeJWTHuman(w, token, keyStr, checks) +} + +// decodeJWTHuman prints the decoded JWT and, when claim validation was +// requested, a Claims section after it. Both the signature check and the claim +// check run so their sections are shown together; the command exits nonzero if +// either fails, with the signature verdict taking precedence for the returned +// sentinel (both are suppressed by the top-level error printer). +func decodeJWTHuman(w io.Writer, tokenStr, keyStr string, checks claimChecks) error { + derr := decodeAndPrint(w, tokenStr, keyStr) + if derr != nil && !errors.Is(derr, errInvalidSignature) { + return derr + } + + var cerr error + if checks.requested() { + if _, err := fmt.Fprintln(w); err != nil { + return err + } + cerr = verifyClaims(w, tokenStr, checks) + if cerr != nil && !errors.Is(cerr, errInvalidClaims) { + return cerr + } + } + + if derr != nil { + return derr } - return decodeAndPrint(w, token, keyStr) + return cerr } // applyColorMode maps the --color flag onto fatih/color's global switch. "auto"