Skip to content
Open
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
52 changes: 52 additions & 0 deletions cli/src/internal/cmd/jwt.go
Original file line number Diff line number Diff line change
@@ -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 <token>",
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
}
81 changes: 81 additions & 0 deletions cli/src/internal/cmd/jwt_test.go
Original file line number Diff line number Diff line change
@@ -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 <token>" {
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")
}
}
1 change: 1 addition & 0 deletions cli/src/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ Examples:
NewDoctorCommand(),
NewGraphCommand(),
NewWhoamiCommand(),
NewJWTCommand(),
)

return rootCmd
Expand Down
1 change: 1 addition & 0 deletions web/src/pages/reference.astro
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const base = import.meta.env.BASE_URL;
<tr><td><code>azd rest doctor</code></td><td>Diagnose authentication and scope detection issues</td></tr>
<tr><td><code>azd rest graph &lt;kql-query&gt;</code></td><td>Run an Azure Resource Graph (KQL) query</td></tr>
<tr><td><code>azd rest whoami</code></td><td>Show the authenticated Azure identity</td></tr>
<tr><td><code>azd rest jwt &lt;token&gt;</code></td><td>Decode a JWT access token and show its claims</td></tr>
<tr><td><code>azd rest scope &lt;url&gt;</code></td><td>Preview the detected OAuth scope and auth mode for a URL</td></tr>
<tr><td><code>azd rest version</code></td><td>Display extension version</td></tr>
</tbody>
Expand Down
Loading