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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>/cmdline` is world-readable, `/proc/<pid>/environ` is owner-only). Diagnostics go to stderr so stdout stays parseable
Expand All @@ -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)
Expand Down Expand Up @@ -132,8 +140,8 @@ JWTD_KEY=key.pem jwtd <token> # 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.
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
86 changes: 86 additions & 0 deletions claims.go
Original file line number Diff line number Diff line change
@@ -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", "; ")
}
Loading