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