diff --git a/cmd/decode_token.go b/cmd/decode_token.go new file mode 100644 index 0000000..a8d40e3 --- /dev/null +++ b/cmd/decode_token.go @@ -0,0 +1,365 @@ +package cmd + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" + "crypto/sha512" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + "strings" + "time" + + "code.cloudfoundry.org/uaa-cli/cli" + "code.cloudfoundry.org/uaa-cli/config" + "github.com/spf13/cobra" +) + +var signingKey string +var decodeTimes bool + +// knownTimestampFields maps JWT claim names to human-readable labels. +var knownTimestampFields = []struct { + field string + label string +}{ + {"iat", "Issued At"}, + {"exp", "Expires At"}, + {"nbf", "Not Before"}, + {"auth_time", "Auth Time"}, + {"updated_at", "Updated At"}, +} + +func decodeJWTPayload(tokenStr string) (map[string]interface{}, []byte, []byte, error) { + parts := strings.Split(tokenStr, ".") + if len(parts) != 3 { + return nil, nil, nil, errors.New("invalid JWT: expected 3 parts separated by '.'") + } + + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid JWT payload encoding: %v", err) + } + + var claims map[string]interface{} + if err := json.Unmarshal(payloadBytes, &claims); err != nil { + return nil, nil, nil, fmt.Errorf("invalid JWT payload JSON: %v", err) + } + + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid JWT header encoding: %v", err) + } + + sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid JWT signature encoding: %v", err) + } + _ = sigBytes + + return claims, headerBytes, sigBytes, nil +} + +func verifyJWTSignature(tokenStr string, keyPEM string) error { + parts := strings.Split(tokenStr, ".") + if len(parts) != 3 { + return errors.New("invalid JWT format") + } + + var header map[string]interface{} + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return fmt.Errorf("invalid JWT header: %v", err) + } + if err := json.Unmarshal(headerBytes, &header); err != nil { + return fmt.Errorf("invalid JWT header JSON: %v", err) + } + + alg, _ := header["alg"].(string) + signingInput := parts[0] + "." + parts[1] + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return fmt.Errorf("invalid JWT signature: %v", err) + } + + block, _ := pem.Decode([]byte(keyPEM)) + if block == nil { + return errors.New("failed to decode PEM block from key") + } + + switch alg { + case "RS256", "RS384", "RS512": + pub, err := parseRSAPublicKey(block) + if err != nil { + return err + } + return verifyRSA(alg, signingInput, sig, pub) + case "ES256", "ES384", "ES512": + pub, err := parseECPublicKey(block) + if err != nil { + return err + } + return verifyECDSA(alg, signingInput, sig, pub) + default: + return fmt.Errorf("unsupported algorithm: %s", alg) + } +} + +func parseRSAPublicKey(block *pem.Block) (*rsa.PublicKey, error) { + switch block.Type { + case "PUBLIC KEY": + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse public key: %v", err) + } + rsaKey, ok := key.(*rsa.PublicKey) + if !ok { + return nil, errors.New("key is not an RSA public key") + } + return rsaKey, nil + case "CERTIFICATE": + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %v", err) + } + rsaKey, ok := cert.PublicKey.(*rsa.PublicKey) + if !ok { + return nil, errors.New("certificate does not contain an RSA public key") + } + return rsaKey, nil + default: + return nil, fmt.Errorf("unsupported PEM block type for RSA: %s", block.Type) + } +} + +func parseECPublicKey(block *pem.Block) (*ecdsa.PublicKey, error) { + switch block.Type { + case "PUBLIC KEY": + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse public key: %v", err) + } + ecKey, ok := key.(*ecdsa.PublicKey) + if !ok { + return nil, errors.New("key is not an EC public key") + } + return ecKey, nil + case "CERTIFICATE": + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %v", err) + } + ecKey, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok { + return nil, errors.New("certificate does not contain an EC public key") + } + return ecKey, nil + default: + return nil, fmt.Errorf("unsupported PEM block type for EC: %s", block.Type) + } +} + +func verifyRSA(alg, signingInput string, sig []byte, pub *rsa.PublicKey) error { + var hash crypto.Hash + var h interface { + Write([]byte) (int, error) + Sum([]byte) []byte + } + switch alg { + case "RS256": + hash = crypto.SHA256 + s := sha256.New() + h = s + case "RS384": + hash = crypto.SHA384 + s := sha512.New384() + h = s + case "RS512": + hash = crypto.SHA512 + s := sha512.New() + h = s + } + h.Write([]byte(signingInput)) + digest := h.Sum(nil) + if err := rsa.VerifyPKCS1v15(pub, hash, digest, sig); err != nil { + return errors.New("invalid token signature") + } + return nil +} + +func verifyECDSA(alg, signingInput string, sig []byte, pub *ecdsa.PublicKey) error { + var h interface { + Write([]byte) (int, error) + Sum([]byte) []byte + } + var keySize int + switch alg { + case "ES256": + s := sha256.New() + h = s + keySize = 32 + case "ES384": + s := sha512.New384() + h = s + keySize = 48 + case "ES512": + s := sha512.New() + h = s + keySize = 66 + default: + return fmt.Errorf("unsupported EC algorithm: %s", alg) + } + h.Write([]byte(signingInput)) + digest := h.Sum(nil) + + if len(sig) != 2*keySize { + return errors.New("invalid token signature: unexpected signature length") + } + r := new(big.Int).SetBytes(sig[:keySize]) + s := new(big.Int).SetBytes(sig[keySize:]) + + // Validate that the public key curve matches the expected key size. + switch alg { + case "ES256": + if pub.Curve != elliptic.P256() { + return errors.New("key curve does not match ES256") + } + case "ES384": + if pub.Curve != elliptic.P384() { + return errors.New("key curve does not match ES384") + } + case "ES512": + if pub.Curve != elliptic.P521() { + return errors.New("key curve does not match ES512") + } + } + + if !ecdsa.Verify(pub, digest, r, s) { + return errors.New("invalid token signature") + } + return nil +} + +func printDecodedTimestamps(claims map[string]interface{}) { + now := time.Now() + printed := false + for _, tf := range knownTimestampFields { + v, ok := claims[tf.field] + if !ok { + continue + } + var epoch int64 + switch n := v.(type) { + case float64: + epoch = int64(n) + case json.Number: + epoch, _ = n.Int64() + default: + continue + } + t := time.Unix(epoch, 0).UTC() + rel := relativeTime(t, now) + if !printed { + log.Info("--- Decoded timestamps ---") + printed = true + } + log.Info(fmt.Sprintf("%-12s %-16s %s (%s)", tf.field, "("+tf.label+"):", t.Format("2006-01-02 15:04:05 UTC"), rel)) + } +} + +func relativeTime(t, now time.Time) string { + diff := t.Sub(now) + abs := diff + if abs < 0 { + abs = -abs + } + + var unit string + var n int64 + switch { + case abs < time.Minute: + n = int64(abs.Seconds()) + unit = "second" + case abs < time.Hour: + n = int64(abs.Minutes()) + unit = "minute" + case abs < 24*time.Hour: + n = int64(abs.Hours()) + unit = "hour" + default: + n = int64(abs.Hours() / 24) + unit = "day" + } + if n != 1 { + unit += "s" + } + if diff < 0 { + return fmt.Sprintf("%d %s ago", n, unit) + } + return fmt.Sprintf("in %d %s", n, unit) +} + +func DecodeTokenCmd(cfg config.Config, args []string) error { + var tokenStr string + + if len(args) > 0 { + tokenStr = args[0] + } else { + ctx := cfg.GetActiveContext() + if ctx.Token.AccessToken == "" { + return errors.New("no token provided and no token found in active context") + } + tokenStr = ctx.Token.AccessToken + } + + claims, _, _, err := decodeJWTPayload(tokenStr) + if err != nil { + return err + } + + if signingKey != "" { + if err := verifyJWTSignature(tokenStr, signingKey); err != nil { + return err + } + log.Info("Valid token signature.") + } + + if err := cli.NewJsonPrinter(log).Print(claims); err != nil { + return err + } + + if decodeTimes { + printDecodedTimestamps(claims) + } + + return nil +} + +var decodeTokenCmd = &cobra.Command{ + Use: "decode-token [TOKEN]", + Short: "Decode a JWT token and display its claims", + Long: `Decode a JWT token and display its claims as JSON. + +If TOKEN is not provided, the access token from the active context is used. +Use --key to verify the token signature against a PEM-encoded public key. +Use --decode-times to print human-readable timestamps for iat, exp, nbf, and other date fields.`, + Run: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + cli.NotifyErrorsWithRetry(DecodeTokenCmd(cfg, args), log, cfg) + }, +} + +func init() { + RootCmd.AddCommand(decodeTokenCmd) + decodeTokenCmd.Flags().StringVarP(&signingKey, "key", "", "", "PEM-encoded public key or certificate for signature verification") + decodeTokenCmd.Flags().BoolVarP(&decodeTimes, "decode-times", "d", false, "Print human-readable timestamps for date fields (iat, exp, nbf, etc.)") + decodeTokenCmd.Annotations = make(map[string]string) + decodeTokenCmd.Annotations[TOKEN_CATEGORY] = "true" +} diff --git a/cmd/decode_token_test.go b/cmd/decode_token_test.go new file mode 100644 index 0000000..e00ad2b --- /dev/null +++ b/cmd/decode_token_test.go @@ -0,0 +1,152 @@ +package cmd_test + +import ( + "code.cloudfoundry.org/uaa-cli/config" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gbytes" + . "github.com/onsi/gomega/gexec" +) + +// testRS256JWT is a real RS256-signed JWT with payload: +// +// {"sub":"abc123","iss":"https://uaa.example.com/oauth/token", +// "iat":1505079823,"exp":1507671823,"jti":"test-jti"} +// +// Signed with testRS256PublicKey's corresponding private key. +const testRS256JWT = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" + + ".eyJzdWIiOiJhYmMxMjMiLCJpc3MiOiJodHRwczovL3VhYS5leGFtcGxlLmNvbS9vYXV0aC90b2tlbiIsImlhdCI6MTUwNTA3OTgyMywiZXhwIjoxNTA3NjcxODIzLCJqdGkiOiJ0ZXN0LWp0aSJ9" + + ".hE_Re8Exrxe86wNfzYeDCXohy-d-QaqUCS3WoZ5wtUs7GSRbXeZubwT62MxO2FXl2iVx3NYphsaJ7P7IABvMP6UsPEYZ3oOdgGyXOBIki6GqnHiMueE5hwNKyETsmovRJmVo7PHPceYKM1J2RugQan1np8ELMMLGgWJQAjOD4TqOUPQC6CYfIZcaVATClV_lXFYun7_6hox6N7QooEB27-YquZYK88gSspvRC9m19VnuK4UuGqa0VpPHBfde_k1aPl-cabj-kvcywYXnwY4pZYwJas3Prvm8cxEQK49V7Bemf89qwNHhn-0VEXtPcwLn3Xcc7DWM0fFyN1qx2MepPg" + +const testRS256PublicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzj9b79BofXFY7pcLrDjv +njB/vHHbLwc33sAa0eiPu3n6tgi7z7k/VpXt7s5/CiGhcJt1oHb7b8zpS0Vg96Is +p4KbPIwA+GTAYqEPDLLIzxPxB13lVUXMUmhqc2oY/ryRpYs87WywT4maaaabwV1H +2v2LWUXnNO7zeFz7amXAwLjpombEEGZbA6AGt21PWnLfhRxyb/+Vk0h1enF+SSH8 +B8nG6D55RaNCgQ/GduTll9cQu2GHV2npmyEagr80dGNS0fvprWf9zZjzfsXZUH+Z +5PabXmqiYnHPAiRkPgv1BkLeYt72t4IykRB7Cws6h7ltP8zY89x2YgxY+OtSBX8q +AQIDAQAB +-----END PUBLIC KEY-----` + +const testRS256WrongPublicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2a2rwplBQLF29amygykE +MmYz0+Kcj3bKBp29P2rFj7bQMBqBaMhBNjRnSFBqHHDEIxJBBGJFCFBJCBBBBBB +BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBw +IDAQAB +-----END PUBLIC KEY-----` + +var _ = Describe("DecodeToken", func() { + Describe("when no token is in context and no arg is given", func() { + BeforeEach(func() { + c := config.NewConfigWithServerURL(server.URL()) + Expect(config.WriteConfig(c)).Error().ShouldNot(HaveOccurred()) + }) + + It("exits 1 with a helpful error", func() { + session := runCommand("decode-token") + + Eventually(session).Should(Exit(1)) + Expect(session.Err).To(Say("no token")) + }) + }) + + Describe("when a token is in the active context", func() { + BeforeEach(func() { + c := config.NewConfigWithServerURL(server.URL()) + ctx := config.NewContextWithToken(testRS256JWT) + c.AddContext(ctx) + Expect(config.WriteConfig(c)).Error().ShouldNot(HaveOccurred()) + }) + + It("exits 0 and prints decoded claims as JSON", func() { + session := runCommand("decode-token") + + Eventually(session).Should(Exit(0)) + output := string(session.Out.Contents()) + Expect(output).To(ContainSubstring(`"sub"`)) + Expect(output).To(ContainSubstring(`"abc123"`)) + Expect(output).To(ContainSubstring(`"iss"`)) + Expect(output).To(ContainSubstring(`uaa.example.com`)) + }) + + It("exits 0 with --verbose flag", func() { + session := runCommand("decode-token", "--verbose") + + Eventually(session).Should(Exit(0)) + Expect(string(session.Out.Contents())).To(ContainSubstring(`"sub"`)) + }) + }) + + Describe("when a token is passed as a positional argument", func() { + BeforeEach(func() { + c := config.NewConfig() + Expect(config.WriteConfig(c)).Error().ShouldNot(HaveOccurred()) + }) + + It("exits 0 and prints decoded claims", func() { + session := runCommand("decode-token", testRS256JWT) + + Eventually(session).Should(Exit(0)) + output := string(session.Out.Contents()) + Expect(output).To(ContainSubstring(`"sub"`)) + Expect(output).To(ContainSubstring(`"abc123"`)) + Expect(output).To(ContainSubstring(`"jti"`)) + Expect(output).To(ContainSubstring(`"test-jti"`)) + }) + + It("exits 0 when a token type is also passed as a second argument", func() { + session := runCommand("decode-token", testRS256JWT, "bearer") + + Eventually(session).Should(Exit(0)) + Expect(string(session.Out.Contents())).To(ContainSubstring(`"sub"`)) + }) + }) + + Describe("--key flag", func() { + BeforeEach(func() { + c := config.NewConfig() + Expect(config.WriteConfig(c)).Error().ShouldNot(HaveOccurred()) + }) + + It("exits 0 and prints 'Valid token signature' when key matches", func() { + session := runCommand("decode-token", testRS256JWT, "--key", testRS256PublicKey) + + Eventually(session).Should(Exit(0)) + output := string(session.Out.Contents()) + Expect(output).To(ContainSubstring("Valid token signature")) + Expect(output).To(ContainSubstring(`"sub"`)) + }) + + It("exits 1 with a signature error when key does not match", func() { + session := runCommand("decode-token", testRS256JWT, "--key", testRS256WrongPublicKey) + + Eventually(session).Should(Exit(1)) + }) + }) + + Describe("--decode-times flag", func() { + BeforeEach(func() { + c := config.NewConfig() + Expect(config.WriteConfig(c)).Error().ShouldNot(HaveOccurred()) + }) + + It("exits 0 and prints a human-readable timestamp section", func() { + session := runCommand("decode-token", testRS256JWT, "--decode-times") + + Eventually(session).Should(Exit(0)) + output := string(session.Out.Contents()) + Expect(output).To(ContainSubstring("Decoded timestamps")) + Expect(output).To(ContainSubstring("iat")) + Expect(output).To(ContainSubstring("Issued At")) + Expect(output).To(ContainSubstring("exp")) + Expect(output).To(ContainSubstring("Expires At")) + }) + + It("does not print timestamp section without the flag", func() { + session := runCommand("decode-token", testRS256JWT) + + Eventually(session).Should(Exit(0)) + Expect(string(session.Out.Contents())).NotTo(ContainSubstring("Decoded timestamps")) + }) + }) +}) diff --git a/docs/commands.md b/docs/commands.md index 37b5d3a..48a8cdc 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -9,6 +9,7 @@ Each command name below links to a page with a full description, including all a | Command | Description | |---------|-------------| | [`target`](commands/target.md) | Set the URL of the UAA you'd like to target | +| [`targets`](commands/targets.md) | List all registered targets | | [`context`](commands/context.md) | See information about the currently active CLI context | | [`info`](commands/info.md) | See version and global configurations for the targeted UAA | | [`version`](commands/version.md) | Print CLI version | @@ -24,6 +25,7 @@ Each command name below links to a page with a full description, including all a | [`refresh-token`](commands/refresh-token.md) | Obtain a new access token using a refresh token | | [`get-token-key`](commands/get-token-key.md) | View the key for validating UAA's JWT token signatures | | [`get-token-keys`](commands/get-token-keys.md) | View all keys the UAA has used to sign JWT tokens | +| [`decode-token`](commands/decode-token.md) | Decode a JWT and display its claims; optionally verify signature or show human-readable timestamps | ## Managing Clients diff --git a/docs/commands/decode-token.md b/docs/commands/decode-token.md new file mode 100644 index 0000000..929f0ba --- /dev/null +++ b/docs/commands/decode-token.md @@ -0,0 +1,115 @@ +# decode-token + +[← Command Reference](../commands.md) + +Decode a JWT token and display its claims as JSON. If no token is provided, the access token from the active context is used. + +## Usage + +``` +uaa decode-token [TOKEN] [flags] +``` + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `TOKEN` | No | A base64url-encoded JWT. Defaults to the access token in the active context. | + +A second positional argument (token type, e.g. `bearer`) is accepted and ignored, for compatibility with `uaac token decode`. + +## Flags + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--key` | | | PEM-encoded public key or certificate for signature verification | +| `--decode-times` | `-d` | `false` | Print human-readable timestamps for date fields (`iat`, `exp`, `nbf`, etc.) | + +## Global Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--verbose` | `-v` | Print additional info on HTTP requests | + +## Output + +By default the command prints the decoded JWT claims as indented JSON, suitable for piping to `jq` or other tools. + +```json +{ + "aud": ["uaa", "admin"], + "client_id": "admin", + "exp": 1507671823, + "iat": 1505079823, + "iss": "https://uaa.example.com/oauth/token", + "jti": "abc123", + "scope": ["uaa.admin"], + "sub": "admin", + "zid": "uaa" +} +``` + +With `--decode-times`, a human-readable timestamp section is appended after the JSON: + +``` +--- Decoded timestamps --- +iat (Issued At): 2017-09-10 18:03:43 UTC (7 years ago) +exp (Expires At): 2017-10-11 06:03:43 UTC (7 years ago) +``` + +With `--key`, the signature is verified before printing claims. If verification succeeds, `Valid token signature.` is printed first. If it fails, the command exits with a non-zero status. + +## Examples + +### Decode the token from the active context + +```bash +uaa target https://uaa.example.com +uaa get-client-credentials-token admin -s adminsecret +uaa decode-token +``` + +### Decode a token passed directly + +```bash +uaa decode-token eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +### Decode with human-readable timestamps + +```bash +uaa decode-token --decode-times +uaa decode-token eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... -d +``` + +### Verify signature against a signing key + +```bash +# Fetch the UAA's public signing key +uaa get-token-key + +# Verify and decode +uaa decode-token --key "$(cat signing-key.pem)" +uaa decode-token eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... --key "$(cat signing-key.pem)" --decode-times +``` + +## Troubleshooting + +**`no token provided and no token found in active context`** +Run a token command first, e.g. `uaa get-client-credentials-token` or `uaa get-password-token`. + +**`invalid token signature`** +The key passed to `--key` does not match the key used to sign the token. Fetch the correct signing key with `uaa get-token-key` or `uaa get-token-keys`. + +**`unsupported algorithm`** +Only RSA (`RS256`, `RS384`, `RS512`) and EC (`ES256`, `ES384`, `ES512`) algorithms are supported for local signature verification. + +## See Also + +- [get-token-key](get-token-key.md) — view the UAA's current JWT signing key +- [get-token-keys](get-token-keys.md) — view all signing keys the UAA has used +- [context](context.md) — view the active context and its access token + +--- + +[← Command Reference](../commands.md)