Skip to content

Commit 4b28b50

Browse files
authored
Merge pull request #1861 from dgageot/debug-auth
Add 'debug auth' command to inspect Docker Desktop JWT
2 parents 5077236 + 348065e commit 4b28b50

File tree

3 files changed

+238
-0
lines changed

3 files changed

+238
-0
lines changed

cmd/root/debug.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ func newDebugCmd() *cobra.Command {
5454

5555
addRuntimeConfigFlags(cmd, &flags.runConfig)
5656

57+
cmd.AddCommand(newDebugAuthCmd())
58+
5759
return cmd
5860
}
5961

cmd/root/debug_auth.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package root
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"time"
8+
9+
"github.com/golang-jwt/jwt/v5"
10+
"github.com/spf13/cobra"
11+
12+
"github.com/docker/cagent/pkg/desktop"
13+
"github.com/docker/cagent/pkg/telemetry"
14+
)
15+
16+
// authInfo holds the parsed JWT authentication information.
17+
type authInfo struct {
18+
Token string `json:"token"`
19+
Subject string `json:"subject,omitempty"`
20+
Issuer string `json:"issuer,omitempty"`
21+
IssuedAt time.Time `json:"issued_at,omitempty"`
22+
ExpiresAt time.Time `json:"expires_at,omitempty"`
23+
Expired bool `json:"expired"`
24+
Username string `json:"username,omitempty"`
25+
Email string `json:"email,omitempty"`
26+
}
27+
28+
func newDebugAuthCmd() *cobra.Command {
29+
var jsonOutput bool
30+
31+
cmd := &cobra.Command{
32+
Use: "auth",
33+
Short: "Print Docker Desktop authentication information",
34+
Args: cobra.NoArgs,
35+
RunE: func(cmd *cobra.Command, _ []string) error {
36+
telemetry.TrackCommand("debug", []string{"auth"})
37+
38+
ctx := cmd.Context()
39+
w := cmd.OutOrStdout()
40+
41+
token := desktop.GetToken(ctx)
42+
if token == "" {
43+
if jsonOutput {
44+
return json.NewEncoder(w).Encode(map[string]string{
45+
"error": "no token found (is Docker Desktop running and are you logged in?)",
46+
})
47+
}
48+
fmt.Fprintln(w, "No token found. Is Docker Desktop running and are you logged in?")
49+
return nil
50+
}
51+
52+
info, err := parseAuthInfo(token)
53+
if err != nil {
54+
return fmt.Errorf("failed to parse JWT: %w", err)
55+
}
56+
57+
userInfo := desktop.GetUserInfo(ctx)
58+
info.Username = userInfo.Username
59+
info.Email = userInfo.Email
60+
61+
if jsonOutput {
62+
enc := json.NewEncoder(w)
63+
enc.SetIndent("", " ")
64+
return enc.Encode(info)
65+
}
66+
67+
printAuthInfoText(w, info)
68+
return nil
69+
},
70+
}
71+
72+
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
73+
74+
return cmd
75+
}
76+
77+
func parseAuthInfo(token string) (*authInfo, error) {
78+
parsed, _, err := jwt.NewParser().ParseUnverified(token, jwt.MapClaims{})
79+
if err != nil {
80+
return nil, err
81+
}
82+
83+
info := &authInfo{
84+
Token: token,
85+
}
86+
87+
if sub, err := parsed.Claims.GetSubject(); err == nil {
88+
info.Subject = sub
89+
}
90+
if iss, err := parsed.Claims.GetIssuer(); err == nil {
91+
info.Issuer = iss
92+
}
93+
if iat, err := parsed.Claims.GetIssuedAt(); err == nil && iat != nil {
94+
info.IssuedAt = iat.Time
95+
}
96+
if exp, err := parsed.Claims.GetExpirationTime(); err == nil && exp != nil {
97+
info.ExpiresAt = exp.Time
98+
info.Expired = exp.Before(time.Now())
99+
}
100+
101+
return info, nil
102+
}
103+
104+
func printAuthInfoText(w io.Writer, info *authInfo) {
105+
fmt.Fprintf(w, "Token: %s...%s\n", info.Token[:10], info.Token[len(info.Token)-10:])
106+
107+
if info.Username != "" {
108+
fmt.Fprintf(w, "Username: %s\n", info.Username)
109+
}
110+
if info.Email != "" {
111+
fmt.Fprintf(w, "Email: %s\n", info.Email)
112+
}
113+
if info.Subject != "" {
114+
fmt.Fprintf(w, "Subject: %s\n", info.Subject)
115+
}
116+
if info.Issuer != "" {
117+
fmt.Fprintf(w, "Issuer: %s\n", info.Issuer)
118+
}
119+
if !info.IssuedAt.IsZero() {
120+
fmt.Fprintf(w, "Issued at: %s\n", info.IssuedAt.Local().Format(time.RFC3339))
121+
}
122+
if !info.ExpiresAt.IsZero() {
123+
fmt.Fprintf(w, "Expires at: %s\n", info.ExpiresAt.Local().Format(time.RFC3339))
124+
}
125+
126+
if info.Expired {
127+
fmt.Fprintln(w, "Status: ❌ Expired")
128+
} else {
129+
fmt.Fprintln(w, "Status: ✅ Valid")
130+
}
131+
}

cmd/root/debug_auth_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package root
2+
3+
import (
4+
"bytes"
5+
"encoding/base64"
6+
"encoding/json"
7+
"fmt"
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func buildTestJWT(claims map[string]any) string {
16+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
17+
payload, _ := json.Marshal(claims)
18+
payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
19+
sig := base64.RawURLEncoding.EncodeToString([]byte("fakesig"))
20+
return fmt.Sprintf("%s.%s.%s", header, payloadB64, sig)
21+
}
22+
23+
func TestParseAuthInfo_ValidToken(t *testing.T) {
24+
t.Parallel()
25+
26+
now := time.Now()
27+
exp := now.Add(time.Hour)
28+
token := buildTestJWT(map[string]any{
29+
"sub": "user-123",
30+
"iss": "docker",
31+
"iat": now.Unix(),
32+
"exp": exp.Unix(),
33+
})
34+
35+
info, err := parseAuthInfo(token)
36+
require.NoError(t, err)
37+
assert.Equal(t, token, info.Token)
38+
assert.Equal(t, "user-123", info.Subject)
39+
assert.Equal(t, "docker", info.Issuer)
40+
assert.False(t, info.Expired)
41+
assert.WithinDuration(t, now, info.IssuedAt, time.Second)
42+
assert.WithinDuration(t, exp, info.ExpiresAt, time.Second)
43+
}
44+
45+
func TestParseAuthInfo_ExpiredToken(t *testing.T) {
46+
t.Parallel()
47+
48+
exp := time.Now().Add(-time.Hour)
49+
token := buildTestJWT(map[string]any{
50+
"sub": "user-456",
51+
"exp": exp.Unix(),
52+
})
53+
54+
info, err := parseAuthInfo(token)
55+
require.NoError(t, err)
56+
assert.True(t, info.Expired)
57+
assert.Equal(t, "user-456", info.Subject)
58+
}
59+
60+
func TestParseAuthInfo_InvalidToken(t *testing.T) {
61+
t.Parallel()
62+
63+
_, err := parseAuthInfo("not-a-jwt")
64+
require.Error(t, err)
65+
}
66+
67+
func TestPrintAuthInfoText(t *testing.T) {
68+
t.Parallel()
69+
70+
info := &authInfo{
71+
Token: "eyJhbGciOiJIUzI1NiJ9.xxxxxxxxxxxx.yyyyyyyy1234567890",
72+
Username: "testuser",
73+
Email: "test@example.com",
74+
Subject: "sub-123",
75+
Issuer: "docker",
76+
IssuedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
77+
ExpiresAt: time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC),
78+
Expired: false,
79+
}
80+
81+
var buf bytes.Buffer
82+
printAuthInfoText(&buf, info)
83+
84+
output := buf.String()
85+
assert.Contains(t, output, "testuser")
86+
assert.Contains(t, output, "test@example.com")
87+
assert.Contains(t, output, "sub-123")
88+
assert.Contains(t, output, "docker")
89+
assert.Contains(t, output, "✅ Valid")
90+
}
91+
92+
func TestPrintAuthInfoText_Expired(t *testing.T) {
93+
t.Parallel()
94+
95+
info := &authInfo{
96+
Token: "eyJhbGciOiJIUzI1NiJ9.xxxxxxxxxxxx.yyyyyyyy1234567890",
97+
ExpiresAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
98+
Expired: true,
99+
}
100+
101+
var buf bytes.Buffer
102+
printAuthInfoText(&buf, info)
103+
104+
assert.Contains(t, buf.String(), "❌ Expired")
105+
}

0 commit comments

Comments
 (0)