diff --git a/internal/cli/verify.go b/internal/cli/verify.go index 13b17d4..67c691a 100644 --- a/internal/cli/verify.go +++ b/internal/cli/verify.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" + "strings" "time" ucli "github.com/urfave/cli/v3" @@ -23,32 +25,60 @@ func tokenVerifyCommand(streams IOStreams) *ucli.Command { return &ucli.Command{ Name: "verify", Usage: "Verify a JWT against the configured issuer over HTTPS", - ArgsUsage: "", + ArgsUsage: "[jwt]", Description: "Fetch the issuer's discovery document and JWKS over HTTPS, verify the token's " + "RS256 signature against the published key, and check its standard claims (iss, exp, " + "nbf, iat) with ±60s clock skew. Prints OK and the decoded claims to stderr; nothing " + "is written to stdout.\n\n" + - "Example:\n" + - " jotsmith token verify \"$(jotsmith token mint --sub me)\" --aud sigstore", + "The JWT may be passed as an argument or piped on stdin.\n\n" + + "Examples:\n" + + " jotsmith token verify \"$(jotsmith token mint --sub me)\" --aud sigstore\n" + + " jotsmith token mint --sub me | jotsmith token verify --aud sigstore", Flags: []ucli.Flag{ &ucli.StringFlag{Name: "aud", Usage: "require this audience to be present in the token"}, &ucli.StringFlag{Name: "sub", Usage: "require this exact subject"}, }, Action: func(ctx context.Context, cmd *ucli.Command) error { - args := cmd.Args() - if args.Len() != 1 { - return usageErrorf("token verify requires exactly one argument: the JWT to verify") + token, err := readToken(cmd.Args(), streams) + if err != nil { + return err } cfg, err := loadConfig(ctx, cmd) if err != nil { return err } client := &http.Client{Timeout: verifyHTTPTimeout} - return runVerify(ctx, client, cfg, args.First(), cmd.String("aud"), cmd.String("sub"), streams) + return runVerify(ctx, client, cfg, token, cmd.String("aud"), cmd.String("sub"), streams) }, } } +// readToken resolves the JWT to verify from either a single positional argument +// or, when none is given, from stdin. Reading from stdin requires the stream to +// be non-interactive (piped or redirected); on a TTY there is nothing to read, +// so it returns a usage error rather than blocking on input. +func readToken(args ucli.Args, streams IOStreams) (string, error) { + switch args.Len() { + case 1: + return args.First(), nil + case 0: + if isTerminal(streams.In) { + return "", usageErrorf("token verify requires a JWT as an argument or piped on stdin") + } + b, err := io.ReadAll(streams.In) + if err != nil { + return "", failuref("reading token from stdin: %v", err) + } + token := strings.TrimSpace(string(b)) + if token == "" { + return "", usageErrorf("token verify requires a JWT as an argument or piped on stdin") + } + return token, nil + default: + return "", usageErrorf("token verify accepts at most one argument: the JWT to verify") + } +} + // runVerify fetches the issuer's JWKS, verifies the token, and reports the // result to stderr. Verification and fetch failures are runtime failures (exit // code 1); nothing is ever written to stdout. diff --git a/internal/cli/verify_test.go b/internal/cli/verify_test.go index ed83105..a8e161a 100644 --- a/internal/cli/verify_test.go +++ b/internal/cli/verify_test.go @@ -140,3 +140,45 @@ func TestTokenVerifyMissingArgIsUsageError(t *testing.T) { t.Errorf("expected exit %d, got %d", exitUsage, got) } } + +// stringArgs is a minimal ucli.Args implementation for testing readToken. +type stringArgs []string + +func (a stringArgs) Get(n int) string { + if n < 0 || n >= len(a) { + return "" + } + return a[n] +} +func (a stringArgs) First() string { return a.Get(0) } +func (a stringArgs) Tail() []string { + if len(a) < 2 { + return []string{} + } + return a[1:] +} +func (a stringArgs) Len() int { return len(a) } +func (a stringArgs) Present() bool { return len(a) > 0 } +func (a stringArgs) Slice() []string { return append([]string(nil), a...) } + +func TestReadToken(t *testing.T) { + // Single positional argument wins and is returned verbatim. + if tok, err := readToken(stringArgs{"a.b.c"}, IOStreams{In: strings.NewReader("ignored")}); err != nil || tok != "a.b.c" { + t.Fatalf("arg path: got %q, %v", tok, err) + } + + // No argument, token piped on stdin (with surrounding whitespace trimmed). + if tok, err := readToken(stringArgs{}, IOStreams{In: strings.NewReader(" x.y.z\n")}); err != nil || tok != "x.y.z" { + t.Fatalf("stdin path: got %q, %v", tok, err) + } + + // No argument, empty stdin is a usage error. + if _, err := readToken(stringArgs{}, IOStreams{In: strings.NewReader("")}); err == nil || ExitCode(err) != exitUsage { + t.Fatalf("empty stdin should be usage error, got %v (code %d)", err, ExitCode(err)) + } + + // Too many arguments is a usage error. + if _, err := readToken(stringArgs{"a", "b"}, IOStreams{In: strings.NewReader("")}); err == nil || ExitCode(err) != exitUsage { + t.Fatalf("extra args should be usage error, got %v (code %d)", err, ExitCode(err)) + } +}