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
44 changes: 37 additions & 7 deletions internal/cli/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"

ucli "github.com/urfave/cli/v3"
Expand All @@ -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: "<jwt>",
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.
Expand Down
42 changes: 42 additions & 0 deletions internal/cli/verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Loading