From f6005c728ce2e24bdc611ba3f518ebac6924f042 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Sat, 25 Apr 2026 23:28:25 +0800 Subject: [PATCH 1/7] feat(cli): add token decode subcommand - Add token decode subcommand that base64-decodes a JWT access token payload locally without contacting the OAuth server - Support -f/--field flag to extract a single named claim such as aud, sub, or project_id - Print string claims raw and non-string claims JSON-encoded for shell-friendly use - Reject opaque tokens with a hint pointing at token inspect Co-Authored-By: Claude Opus 4.7 (1M context) --- token_cmd.go | 117 +++++++++++++++++++++++++++++ token_cmd_test.go | 183 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) diff --git a/token_cmd.go b/token_cmd.go index a00f3ec..013dc06 100644 --- a/token_cmd.go +++ b/token_cmd.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -35,6 +36,7 @@ func buildTokenCmd() *cobra.Command { tokenCmd.AddCommand(buildTokenGetCmd()) tokenCmd.AddCommand(buildTokenDeleteCmd()) tokenCmd.AddCommand(buildTokenInspectCmd()) + tokenCmd.AddCommand(buildTokenDecodeCmd()) return tokenCmd } @@ -325,6 +327,121 @@ func runTokenGet( return 0 } +func buildTokenDecodeCmd() *cobra.Command { + var field string + cmd := &cobra.Command{ + Use: "decode", + Short: "Decode the stored access token's claims locally (JWT only)", + Long: `Decode the stored access token's claims by base64-decoding its +JWT payload locally, without contacting the OAuth server. + +The signature is NOT verified — use 'token inspect' for that. Useful +when the access token is a JWT (e.g. contains aud, sub, project_id). +If the token is opaque (not a JWT), this command fails with an error.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := loadStoreConfig() + if code := runTokenDecode( + cfg.Store, + cfg.ClientID, + field, + cmd.OutOrStdout(), + cmd.ErrOrStderr(), + ); code != 0 { + return exitCodeError(code) + } + return nil + }, + } + cmd.Flags(). + StringVarP(&field, "field", "f", "", "Print only the named top-level claim (e.g. aud, sub, project_id)") + return cmd +} + +// parseJWTPayload decodes a JWT's payload (claims) without verifying the +// signature. Use only for local inspection — claims must not drive +// authorization; use the server's introspection endpoint for that. +func parseJWTPayload(token string) (map[string]any, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, fmt.Errorf( + "not a JWT-formatted token (expected 3 dot-separated parts, got %d)", + len(parts), + ) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("decode payload: %w", err) + } + var claims map[string]any + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("parse claims: %w", err) + } + return claims, nil +} + +// runTokenDecode is the testable core of `token decode`. It locally parses +// the stored access token as a JWT and prints either the full claims map or +// a single named claim. +func runTokenDecode( + store credstore.Store[credstore.Token], + id string, + field string, + stdout io.Writer, + stderr io.Writer, +) int { + tok, code := loadTokenOrFail(store, id, stderr) + if code != 0 { + return code + } + claims, err := parseJWTPayload(tok.AccessToken) + if err != nil { + fmt.Fprintf(stderr, "Error: failed to decode access token: %v\n", err) + fmt.Fprintln( + stderr, + "Hint: if the token is opaque, use 'token inspect' to query the server.", + ) + return 1 + } + if field != "" { + return printClaimField(claims, field, stdout, stderr) + } + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + if err := enc.Encode(claims); err != nil { + fmt.Fprintf(stderr, "Error: failed to write output: %v\n", err) + return 1 + } + return 0 +} + +// printClaimField writes a single claim to stdout. String values are printed +// raw (like `jq -r`) so they're shell-friendly; other types stay JSON-encoded +// so arrays/objects/numbers remain machine-readable. +func printClaimField( + claims map[string]any, + field string, + stdout io.Writer, + stderr io.Writer, +) int { + v, ok := claims[field] + if !ok { + fmt.Fprintf(stderr, "Error: claim %q not found in token\n", field) + return 1 + } + if s, isStr := v.(string); isStr { + fmt.Fprintln(stdout, s) + return 0 + } + b, err := json.Marshal(v) + if err != nil { + fmt.Fprintf(stderr, "Error: failed to encode claim: %v\n", err) + return 1 + } + fmt.Fprintln(stdout, string(b)) + return 0 +} + // runTokenInspect is the testable core of `token inspect`. It calls the // OAuth server's /oauth/tokeninfo endpoint with the stored access token and // prints the response as pretty-printed JSON. If the body is not valid JSON, diff --git a/token_cmd_test.go b/token_cmd_test.go index c5540c6..da0c6c4 100644 --- a/token_cmd_test.go +++ b/token_cmd_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "net/http" @@ -603,3 +604,185 @@ func TestRunTokenInspect(t *testing.T) { } }) } + +// makeJWT builds an unsigned JWT-shaped token for testing parseJWTPayload. +// The signature segment is a fixed placeholder — runTokenDecode never +// verifies it. +func makeJWT(t *testing.T, claims map[string]any) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString( + []byte(`{"alg":"HS256","typ":"JWT"}`), + ) + payloadBytes, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + sig := base64.RawURLEncoding.EncodeToString([]byte("not-a-real-signature")) + return header + "." + payload + "." + sig +} + +func TestRunTokenDecode(t *testing.T) { + t.Run("no token stored", func(t *testing.T) { + store := credstore.NewTokenFileStore( + filepath.Join(t.TempDir(), "tokens.json"), + ) + var stdout, stderr bytes.Buffer + code := runTokenDecode(store, "test-id", "", &stdout, &stderr) + if code != 1 { + t.Errorf("exit code: got %d, want 1", code) + } + if !strings.Contains(stderr.String(), "no stored token") { + t.Errorf("expected 'no stored token' in stderr, got: %q", stderr.String()) + } + }) + + t.Run("opaque token rejected", func(t *testing.T) { + store := credstore.NewTokenFileStore( + filepath.Join(t.TempDir(), "tokens.json"), + ) + if err := store.Save("test-id", credstore.Token{ + AccessToken: "opaque-token-no-dots", + ExpiresAt: time.Now().Add(time.Hour), + ClientID: "test-id", + }); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := runTokenDecode(store, "test-id", "", &stdout, &stderr) + if code != 1 { + t.Errorf("exit code: got %d, want 1", code) + } + if !strings.Contains(stderr.String(), "not a JWT") { + t.Errorf("expected 'not a JWT' in stderr, got: %q", stderr.String()) + } + if !strings.Contains(stderr.String(), "token inspect") { + t.Errorf("expected hint pointing at 'token inspect', got: %q", stderr.String()) + } + }) + + t.Run("invalid base64 payload", func(t *testing.T) { + store := credstore.NewTokenFileStore( + filepath.Join(t.TempDir(), "tokens.json"), + ) + if err := store.Save("test-id", credstore.Token{ + AccessToken: "header.!!!not-base64!!!.sig", + ExpiresAt: time.Now().Add(time.Hour), + ClientID: "test-id", + }); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := runTokenDecode(store, "test-id", "", &stdout, &stderr) + if code != 1 { + t.Errorf("exit code: got %d, want 1", code) + } + if !strings.Contains(stderr.String(), "decode payload") { + t.Errorf("expected 'decode payload' in stderr, got: %q", stderr.String()) + } + }) + + t.Run("full claims pretty printed", func(t *testing.T) { + store := credstore.NewTokenFileStore( + filepath.Join(t.TempDir(), "tokens.json"), + ) + jwt := makeJWT(t, map[string]any{ + "aud": "my-service", + "sub": "service-account@example.iam", + "project_id": "my-project", + "scope": "email profile", + "exp": 1800000000, + }) + if err := store.Save("test-id", credstore.Token{ + AccessToken: jwt, + ExpiresAt: time.Now().Add(time.Hour), + ClientID: "test-id", + }); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := runTokenDecode(store, "test-id", "", &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code: got %d, want 0; stderr: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), " \"aud\": \"my-service\"") { + t.Errorf("expected pretty-printed JSON with aud claim, got: %q", stdout.String()) + } + var parsed map[string]any + if err := json.Unmarshal(stdout.Bytes(), &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if parsed["project_id"] != "my-project" { + t.Errorf("project_id: got %v, want my-project", parsed["project_id"]) + } + }) + + t.Run("field flag string value raw", func(t *testing.T) { + store := credstore.NewTokenFileStore( + filepath.Join(t.TempDir(), "tokens.json"), + ) + jwt := makeJWT(t, map[string]any{"aud": "my-service", "sub": "user-1"}) + if err := store.Save("test-id", credstore.Token{ + AccessToken: jwt, + ExpiresAt: time.Now().Add(time.Hour), + ClientID: "test-id", + }); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := runTokenDecode(store, "test-id", "aud", &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code: got %d, want 0; stderr: %s", code, stderr.String()) + } + if stdout.String() != "my-service\n" { + t.Errorf("got %q, want %q", stdout.String(), "my-service\n") + } + }) + + t.Run("field flag non-string value json encoded", func(t *testing.T) { + store := credstore.NewTokenFileStore( + filepath.Join(t.TempDir(), "tokens.json"), + ) + jwt := makeJWT(t, map[string]any{ + "aud": []string{"svc-a", "svc-b"}, + "groups": map[string]any{"role": "admin"}, + }) + if err := store.Save("test-id", credstore.Token{ + AccessToken: jwt, + ExpiresAt: time.Now().Add(time.Hour), + ClientID: "test-id", + }); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := runTokenDecode(store, "test-id", "aud", &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code: got %d, want 0; stderr: %s", code, stderr.String()) + } + if stdout.String() != `["svc-a","svc-b"]`+"\n" { + t.Errorf("got %q, want JSON-encoded array", stdout.String()) + } + }) + + t.Run("field flag missing claim", func(t *testing.T) { + store := credstore.NewTokenFileStore( + filepath.Join(t.TempDir(), "tokens.json"), + ) + jwt := makeJWT(t, map[string]any{"sub": "user-1"}) + if err := store.Save("test-id", credstore.Token{ + AccessToken: jwt, + ExpiresAt: time.Now().Add(time.Hour), + ClientID: "test-id", + }); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := runTokenDecode(store, "test-id", "aud", &stdout, &stderr) + if code != 1 { + t.Errorf("exit code: got %d, want 1", code) + } + if !strings.Contains(stderr.String(), `claim "aud" not found`) { + t.Errorf("expected missing-claim message, got: %q", stderr.String()) + } + }) +} From 5968afd4ae8e986bc4c3d7a17ee571b10ca6d83a Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Sun, 26 Apr 2026 07:26:34 +0800 Subject: [PATCH 2/7] fix(cli): scope opaque-token hint and bound JWT split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce errNotJWT sentinel so the "use token inspect" hint only fires for non-JWT-shaped tokens, not malformed JWTs - Use strings.SplitN with cap 4 to bound allocation on tokens with many separators - Reword decode help text — token inspect queries the server for validity, not local signature verification - Add test for too-many-segments rejection and assert hint is suppressed for malformed JWT payloads Co-Authored-By: Claude Opus 4.7 (1M context) --- token_cmd.go | 28 +++++++++++++++++++--------- token_cmd_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/token_cmd.go b/token_cmd.go index 013dc06..7c9486b 100644 --- a/token_cmd.go +++ b/token_cmd.go @@ -335,9 +335,10 @@ func buildTokenDecodeCmd() *cobra.Command { Long: `Decode the stored access token's claims by base64-decoding its JWT payload locally, without contacting the OAuth server. -The signature is NOT verified — use 'token inspect' for that. Useful -when the access token is a JWT (e.g. contains aud, sub, project_id). -If the token is opaque (not a JWT), this command fails with an error.`, +The signature is NOT verified. Use 'token inspect' to query the +server for token validity. Useful when the access token is a JWT +(e.g. contains aud, sub, project_id). If the token is opaque (not +a JWT), this command fails with an error.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { cfg := loadStoreConfig() @@ -358,14 +359,21 @@ If the token is opaque (not a JWT), this command fails with an error.`, return cmd } +// errNotJWT signals that a token is not in JWT format (wrong segment count). +// Distinct from base64/JSON failures so callers can tailor the error message. +var errNotJWT = errors.New("not a JWT-formatted token") + // parseJWTPayload decodes a JWT's payload (claims) without verifying the // signature. Use only for local inspection — claims must not drive // authorization; use the server's introspection endpoint for that. func parseJWTPayload(token string) (map[string]any, error) { - parts := strings.Split(token, ".") + // SplitN with cap 4 bounds allocation when fed a malformed token with + // many separators; we still reject anything that isn't exactly 3 parts. + parts := strings.SplitN(token, ".", 4) if len(parts) != 3 { return nil, fmt.Errorf( - "not a JWT-formatted token (expected 3 dot-separated parts, got %d)", + "%w (expected 3 dot-separated parts, got %d)", + errNotJWT, len(parts), ) } @@ -397,10 +405,12 @@ func runTokenDecode( claims, err := parseJWTPayload(tok.AccessToken) if err != nil { fmt.Fprintf(stderr, "Error: failed to decode access token: %v\n", err) - fmt.Fprintln( - stderr, - "Hint: if the token is opaque, use 'token inspect' to query the server.", - ) + if errors.Is(err, errNotJWT) { + fmt.Fprintln( + stderr, + "Hint: if the token is opaque, use 'token inspect' to query the server.", + ) + } return 1 } if field != "" { diff --git a/token_cmd_test.go b/token_cmd_test.go index da0c6c4..67eea14 100644 --- a/token_cmd_test.go +++ b/token_cmd_test.go @@ -680,6 +680,38 @@ func TestRunTokenDecode(t *testing.T) { if !strings.Contains(stderr.String(), "decode payload") { t.Errorf("expected 'decode payload' in stderr, got: %q", stderr.String()) } + // Hint about opaque tokens must not appear for JWT-shaped-but-corrupted + // tokens — the hint is only meaningful when the token isn't JWT. + if strings.Contains(stderr.String(), "token inspect") { + t.Errorf( + "opaque-token hint should not appear for malformed JWT, got: %q", + stderr.String(), + ) + } + }) + + t.Run("too many segments rejected", func(t *testing.T) { + store := credstore.NewTokenFileStore( + filepath.Join(t.TempDir(), "tokens.json"), + ) + if err := store.Save("test-id", credstore.Token{ + AccessToken: "a.b.c.d.e", + ExpiresAt: time.Now().Add(time.Hour), + ClientID: "test-id", + }); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := runTokenDecode(store, "test-id", "", &stdout, &stderr) + if code != 1 { + t.Errorf("exit code: got %d, want 1", code) + } + if !strings.Contains(stderr.String(), "not a JWT") { + t.Errorf("expected 'not a JWT' in stderr, got: %q", stderr.String()) + } + if !strings.Contains(stderr.String(), "token inspect") { + t.Errorf("expected opaque-token hint in stderr, got: %q", stderr.String()) + } }) t.Run("full claims pretty printed", func(t *testing.T) { From 2500edc6a6dbb10ed28e45a03ce6368e0268cdca Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Sun, 26 Apr 2026 07:32:24 +0800 Subject: [PATCH 3/7] fix(cli): polish decode UX from review feedback - Prefix opaque-token hint with binary name to match the loadTokenOrFail style and avoid copy/paste confusion - Report the true segment count from strings.Count rather than the SplitN-capped slice length, so 5+ segment tokens no longer report "got 4" - Add test coverage for the parse-claims path (valid base64url, invalid JSON), asserting the opaque-token hint stays suppressed Co-Authored-By: Claude Opus 4.7 (1M context) --- token_cmd.go | 6 ++++-- token_cmd_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/token_cmd.go b/token_cmd.go index 7c9486b..956e3f1 100644 --- a/token_cmd.go +++ b/token_cmd.go @@ -369,12 +369,14 @@ var errNotJWT = errors.New("not a JWT-formatted token") func parseJWTPayload(token string) (map[string]any, error) { // SplitN with cap 4 bounds allocation when fed a malformed token with // many separators; we still reject anything that isn't exactly 3 parts. + // Count separators directly so the diagnostic reports the true segment + // count rather than the capped slice length. parts := strings.SplitN(token, ".", 4) if len(parts) != 3 { return nil, fmt.Errorf( "%w (expected 3 dot-separated parts, got %d)", errNotJWT, - len(parts), + strings.Count(token, ".")+1, ) } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) @@ -408,7 +410,7 @@ func runTokenDecode( if errors.Is(err, errNotJWT) { fmt.Fprintln( stderr, - "Hint: if the token is opaque, use 'token inspect' to query the server.", + "Hint: if the token is opaque, use 'authgate-cli token inspect' to query the server.", ) } return 1 diff --git a/token_cmd_test.go b/token_cmd_test.go index 67eea14..8189618 100644 --- a/token_cmd_test.go +++ b/token_cmd_test.go @@ -712,6 +712,42 @@ func TestRunTokenDecode(t *testing.T) { if !strings.Contains(stderr.String(), "token inspect") { t.Errorf("expected opaque-token hint in stderr, got: %q", stderr.String()) } + // Diagnostic must report the true segment count (5), not the capped + // SplitN result (4). + if !strings.Contains(stderr.String(), "got 5") { + t.Errorf("expected accurate segment count 'got 5' in stderr, got: %q", stderr.String()) + } + }) + + t.Run("invalid json payload", func(t *testing.T) { + store := credstore.NewTokenFileStore( + filepath.Join(t.TempDir(), "tokens.json"), + ) + // Valid base64url encoding of bytes that are not JSON. + nonJSON := base64.RawURLEncoding.EncodeToString([]byte("not json at all")) + if err := store.Save("test-id", credstore.Token{ + AccessToken: "header." + nonJSON + ".sig", + ExpiresAt: time.Now().Add(time.Hour), + ClientID: "test-id", + }); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := runTokenDecode(store, "test-id", "", &stdout, &stderr) + if code != 1 { + t.Errorf("exit code: got %d, want 1", code) + } + if !strings.Contains(stderr.String(), "parse claims") { + t.Errorf("expected 'parse claims' in stderr, got: %q", stderr.String()) + } + // JWT-shaped tokens that fail JSON parsing are still JWTs structurally, + // so the opaque-token hint should not appear. + if strings.Contains(stderr.String(), "token inspect") { + t.Errorf( + "opaque-token hint should not appear for JWT with non-JSON payload, got: %q", + stderr.String(), + ) + } }) t.Run("full claims pretty printed", func(t *testing.T) { From 96044e9c359c2cbed4ac0e18b5dfea93b1114879 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Sun, 26 Apr 2026 07:37:43 +0800 Subject: [PATCH 4/7] fix(cli): preserve numeric claim precision in token decode - Use json.Decoder with UseNumber to keep large integer claims (jti, exp, custom IDs) exact, instead of float64 coercion that loses precision past 2^53 - Add test asserting 2^53+1 round-trips through field extraction unchanged Co-Authored-By: Claude Opus 4.7 (1M context) --- token_cmd.go | 6 +++++- token_cmd_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/token_cmd.go b/token_cmd.go index 956e3f1..b538500 100644 --- a/token_cmd.go +++ b/token_cmd.go @@ -383,8 +383,12 @@ func parseJWTPayload(token string) (map[string]any, error) { if err != nil { return nil, fmt.Errorf("decode payload: %w", err) } + // UseNumber preserves integer claims (e.g. exp, iat, custom IDs) exactly; + // the default would coerce them to float64 and lose precision past 2^53. var claims map[string]any - if err := json.Unmarshal(payload, &claims); err != nil { + dec := json.NewDecoder(bytes.NewReader(payload)) + dec.UseNumber() + if err := dec.Decode(&claims); err != nil { return nil, fmt.Errorf("parse claims: %w", err) } return claims, nil diff --git a/token_cmd_test.go b/token_cmd_test.go index 8189618..98a65b2 100644 --- a/token_cmd_test.go +++ b/token_cmd_test.go @@ -832,6 +832,31 @@ func TestRunTokenDecode(t *testing.T) { } }) + t.Run("large integer claim preserved", func(t *testing.T) { + store := credstore.NewTokenFileStore( + filepath.Join(t.TempDir(), "tokens.json"), + ) + // 2^53 + 1 — the smallest positive integer that float64 cannot + // represent exactly. UseNumber must keep it intact. + const bigInt = "9007199254740993" + jwt := makeJWT(t, map[string]any{"jti": json.RawMessage(bigInt)}) + if err := store.Save("test-id", credstore.Token{ + AccessToken: jwt, + ExpiresAt: time.Now().Add(time.Hour), + ClientID: "test-id", + }); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := runTokenDecode(store, "test-id", "jti", &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code: got %d, want 0; stderr: %s", code, stderr.String()) + } + if stdout.String() != bigInt+"\n" { + t.Errorf("got %q, want %q (precision must be preserved)", stdout.String(), bigInt+"\n") + } + }) + t.Run("field flag missing claim", func(t *testing.T) { store := credstore.NewTokenFileStore( filepath.Join(t.TempDir(), "tokens.json"), From 98be01ba885fc9ab70ee89504b4c70e2d6cb09e4 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Sun, 26 Apr 2026 07:44:05 +0800 Subject: [PATCH 5/7] fix(cli): reject trailing data after JWT payload JSON - Verify the JSON decoder reaches io.EOF after parsing claims so payloads with extra bytes or concatenated objects are rejected, not silently accepted - Add test covering two concatenated JSON objects in the payload Co-Authored-By: Claude Opus 4.7 (1M context) --- token_cmd.go | 9 +++++++++ token_cmd_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/token_cmd.go b/token_cmd.go index b538500..a5dfd19 100644 --- a/token_cmd.go +++ b/token_cmd.go @@ -391,6 +391,15 @@ func parseJWTPayload(token string) (map[string]any, error) { if err := dec.Decode(&claims); err != nil { return nil, fmt.Errorf("parse claims: %w", err) } + // Reject trailing bytes (or a second concatenated value) so a malformed + // payload can't masquerade as valid by hiding extra data after the object. + var sink struct{} + if err := dec.Decode(&sink); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New("parse claims: trailing data after JSON object") + } + return nil, fmt.Errorf("parse claims: trailing data: %w", err) + } return claims, nil } diff --git a/token_cmd_test.go b/token_cmd_test.go index 98a65b2..95d925f 100644 --- a/token_cmd_test.go +++ b/token_cmd_test.go @@ -832,6 +832,30 @@ func TestRunTokenDecode(t *testing.T) { } }) + t.Run("trailing data rejected", func(t *testing.T) { + store := credstore.NewTokenFileStore( + filepath.Join(t.TempDir(), "tokens.json"), + ) + // Two concatenated JSON objects in the payload — the first decode + // would succeed silently without an explicit trailing-data check. + bad := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"a"}{"x":1}`)) + if err := store.Save("test-id", credstore.Token{ + AccessToken: "header." + bad + ".sig", + ExpiresAt: time.Now().Add(time.Hour), + ClientID: "test-id", + }); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := runTokenDecode(store, "test-id", "", &stdout, &stderr) + if code != 1 { + t.Errorf("exit code: got %d, want 1", code) + } + if !strings.Contains(stderr.String(), "trailing data") { + t.Errorf("expected 'trailing data' in stderr, got: %q", stderr.String()) + } + }) + t.Run("large integer claim preserved", func(t *testing.T) { store := credstore.NewTokenFileStore( filepath.Join(t.TempDir(), "tokens.json"), From 307750c14fcf375df15bf59611e04a249a3b241b Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Sun, 26 Apr 2026 07:50:08 +0800 Subject: [PATCH 6/7] fix(cli): suppress opaque-token hint for malformed JWTs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Only print the opaque-token hint when the token has fewer than 2 dots — tokens with 4+ segments are clearly malformed JWTs, not opaque, so steering users at 'token inspect' would be misleading - Update too-many-segments test to assert the hint is suppressed for 5-segment tokens Co-Authored-By: Claude Opus 4.7 (1M context) --- token_cmd.go | 5 ++++- token_cmd_test.go | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/token_cmd.go b/token_cmd.go index a5dfd19..59d36a4 100644 --- a/token_cmd.go +++ b/token_cmd.go @@ -420,7 +420,10 @@ func runTokenDecode( claims, err := parseJWTPayload(tok.AccessToken) if err != nil { fmt.Fprintf(stderr, "Error: failed to decode access token: %v\n", err) - if errors.Is(err, errNotJWT) { + // Only suggest "token inspect" when the token looks like it could be + // opaque (fewer dots than a real JWT). Tokens with 4+ segments are + // just malformed JWTs, not opaque, so the hint would mislead. + if errors.Is(err, errNotJWT) && strings.Count(tok.AccessToken, ".") < 2 { fmt.Fprintln( stderr, "Hint: if the token is opaque, use 'authgate-cli token inspect' to query the server.", diff --git a/token_cmd_test.go b/token_cmd_test.go index 95d925f..f59f01d 100644 --- a/token_cmd_test.go +++ b/token_cmd_test.go @@ -709,8 +709,13 @@ func TestRunTokenDecode(t *testing.T) { if !strings.Contains(stderr.String(), "not a JWT") { t.Errorf("expected 'not a JWT' in stderr, got: %q", stderr.String()) } - if !strings.Contains(stderr.String(), "token inspect") { - t.Errorf("expected opaque-token hint in stderr, got: %q", stderr.String()) + // 4+ segment tokens are clearly malformed JWTs, not opaque tokens — + // the opaque-token hint must not appear. + if strings.Contains(stderr.String(), "token inspect") { + t.Errorf( + "opaque-token hint should not appear for too-many-segments token, got: %q", + stderr.String(), + ) } // Diagnostic must report the true segment count (5), not the capped // SplitN result (4). From fbd5f6b794dac654d7ce0654377264db3995ea06 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Sun, 26 Apr 2026 07:55:20 +0800 Subject: [PATCH 7/7] docs(cli): correct makeJWT helper comment to reference runTokenDecode The helper is used in TestRunTokenDecode which exercises parsing indirectly through runTokenDecode, not parseJWTPayload directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- token_cmd_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/token_cmd_test.go b/token_cmd_test.go index f59f01d..d83674f 100644 --- a/token_cmd_test.go +++ b/token_cmd_test.go @@ -605,9 +605,9 @@ func TestRunTokenInspect(t *testing.T) { }) } -// makeJWT builds an unsigned JWT-shaped token for testing parseJWTPayload. -// The signature segment is a fixed placeholder — runTokenDecode never -// verifies it. +// makeJWT builds an unsigned JWT-shaped token for testing runTokenDecode, +// which exercises JWT payload parsing indirectly. The signature segment is a +// fixed placeholder — runTokenDecode never verifies it. func makeJWT(t *testing.T, claims map[string]any) string { t.Helper() header := base64.RawURLEncoding.EncodeToString(