From f8d5094b60d8b6f70a227b7d333983ac25a1d875 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:29:57 -0700 Subject: [PATCH] feat: add jwt command to decode an access token locally Adds an 'azd rest jwt ' subcommand that decodes a JWT locally and prints its claims. Text output shows the identity summary (tenant, object ID, app ID, audience, username, scopes, roles, expiry); --format json prints the full decoded claims. No signature verification, no network call, and the raw token is never written to output. Reuses the local decode path that whoami already uses. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 3 ++ cli/src/internal/cmd/jwt.go | 52 ++++++++++++++++++++ cli/src/internal/cmd/jwt_test.go | 81 ++++++++++++++++++++++++++++++++ cli/src/internal/cmd/root.go | 1 + web/src/pages/reference.astro | 1 + 5 files changed, 138 insertions(+) create mode 100644 cli/src/internal/cmd/jwt.go create mode 100644 cli/src/internal/cmd/jwt_test.go diff --git a/README.md b/README.md index e3157eb..79001ba 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,9 @@ azd rest graph "Resources | summarize count() by type" # Show the signed-in Azure identity (tenant, app, scopes, expiry) azd rest whoami +# Decode a token you already have and print its claims +azd rest jwt "$(az account get-access-token --query accessToken -o tsv)" + # Public API (no auth) azd rest get https://api.github.com/repos/Azure/azure-dev --no-auth diff --git a/cli/src/internal/cmd/jwt.go b/cli/src/internal/cmd/jwt.go new file mode 100644 index 0000000..fcb1507 --- /dev/null +++ b/cli/src/internal/cmd/jwt.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" +) + +// NewJWTCommand returns the jwt subcommand, which decodes an access token and +// prints its claims locally without verifying the signature or making any +// network call. +func NewJWTCommand() *cobra.Command { + return &cobra.Command{ + Use: "jwt ", + Short: "Decode a JWT access token and show its claims", + Long: `Decode a JWT access token locally and print its claims. + +jwt reads the token from its argument, base64-decodes the claims segment, and +prints the tenant, object ID, app ID, audience, username, granted scopes, roles, +and expiry. With --format json it prints the full set of decoded claims. + +The signature is not verified and no network request is made. The raw token is +never written to output. This is useful for inspecting a token from +'az account get-access-token', an Authorization header, or a teammate.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runJWT(args[0], outputFormat, cmd.OutOrStdout()) + }, + } +} + +// runJWT decodes the token's claims and writes them to out. It is separated +// from the cobra command so tests can exercise it directly. +func runJWT(token, format string, out io.Writer) error { + claims, err := decodeJWTClaims(token) + if err != nil { + return fmt.Errorf("failed to decode token: %w", err) + } + + if strings.EqualFold(format, "json") { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return enc.Encode(claims) + } + + writeIdentityText(out, claimsToIdentity(claims)) + return nil +} diff --git a/cli/src/internal/cmd/jwt_test.go b/cli/src/internal/cmd/jwt_test.go new file mode 100644 index 0000000..f5400af --- /dev/null +++ b/cli/src/internal/cmd/jwt_test.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestRunJWT_Text(t *testing.T) { + token := makeJWT(t, map[string]any{ + "tid": "tenant-abc", + "oid": "object-xyz", + "appid": "app-def", + "aud": "https://management.azure.com", + }) + var buf bytes.Buffer + if err := runJWT(token, "", &buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + for _, want := range []string{"Tenant:", "tenant-abc", "Object ID:", "object-xyz", "App ID:", "app-def"} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, token) { + t.Fatalf("raw token must never be printed") + } +} + +func TestRunJWT_JSONClaims(t *testing.T) { + token := makeJWT(t, map[string]any{ + "tid": "tenant-abc", + "oid": "object-xyz", + "scp": "user_impersonation", + }) + var buf bytes.Buffer + if err := runJWT(token, "json", &buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var got map[string]any + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String()) + } + if got["tid"] != "tenant-abc" || got["oid"] != "object-xyz" || got["scp"] != "user_impersonation" { + t.Fatalf("unexpected decoded claims: %#v", got) + } + if strings.Contains(buf.String(), token) { + t.Fatalf("raw token must never be printed") + } +} + +func TestRunJWT_MalformedToken(t *testing.T) { + cases := map[string]string{ + "empty": "", + "two-segment": "header.payload", + "bad-base64": "header.@@@@.sig", + } + for name, token := range cases { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + if err := runJWT(token, "", &buf); err == nil { + t.Fatalf("expected error for %q", name) + } + }) + } +} + +func TestNewJWTCommand_RequiresToken(t *testing.T) { + cmd := NewJWTCommand() + if cmd.Use != "jwt " { + t.Fatalf("unexpected Use: %q", cmd.Use) + } + if err := cmd.Args(cmd, nil); err == nil { + t.Fatal("expected error when no token argument is given") + } + if err := cmd.Args(cmd, []string{"a", "b"}); err == nil { + t.Fatal("expected error when more than one argument is given") + } +} diff --git a/cli/src/internal/cmd/root.go b/cli/src/internal/cmd/root.go index 35e9e2a..1c29f4e 100644 --- a/cli/src/internal/cmd/root.go +++ b/cli/src/internal/cmd/root.go @@ -253,6 +253,7 @@ Examples: NewDoctorCommand(), NewGraphCommand(), NewWhoamiCommand(), + NewJWTCommand(), ) return rootCmd diff --git a/web/src/pages/reference.astro b/web/src/pages/reference.astro index 446eed5..b8570cf 100644 --- a/web/src/pages/reference.astro +++ b/web/src/pages/reference.astro @@ -39,6 +39,7 @@ const base = import.meta.env.BASE_URL; azd rest doctorDiagnose authentication and scope detection issues azd rest graph <kql-query>Run an Azure Resource Graph (KQL) query azd rest whoamiShow the authenticated Azure identity + azd rest jwt <token>Decode a JWT access token and show its claims azd rest scope <url>Preview the detected OAuth scope and auth mode for a URL azd rest versionDisplay extension version