Skip to content

Commit f943eef

Browse files
authored
feat(paseto): add --validate-claims and --clock-skew to decode (Closes #108)
feat(paseto): add --validate-claims and --clock-skew to decode (Closes #108)
1 parent 5135918 commit f943eef

14 files changed

Lines changed: 820 additions & 65 deletions

‎README.md‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,9 @@ Run `jwt-cli paseto genkeys <version>` to print the exact OpenSSL commands for a
155155
given version.
156156

157157
> **Note:** decoding verifies the token's signature or authentication tag, but
158-
> does **not** validate time-based claims — an expired token still decodes, so
159-
> its contents can be inspected.
158+
> does **not** validate time-based claims by default — an expired token still
159+
> decodes, so its contents can be inspected. Pass `--validate-claims` to enforce
160+
> them.
160161
161162
### Local (symmetric) tokens
162163

@@ -229,6 +230,32 @@ $ jwt-cli paseto encode local --key "$KEY" --payload '{ "exp": "2030-01-01T00:00
229230
$ jwt-cli paseto encode local --key "$KEY" --payload '{ "exp": 1893456000 }'
230231
```
231232

233+
### PASETO validation flags
234+
235+
`paseto decode` accepts the same `--validate-claims` and `--clock-skew` flags as
236+
`decode`, with the same defaults and meaning — see
237+
[JWT validation flags](#jwt-validation-flags):
238+
239+
```bash
240+
# Expired token: decodes fine by default
241+
$ jwt-cli paseto decode local --key "$KEY" --token "$TOKEN"
242+
{
243+
"email": "myemail@me.com",
244+
"exp": "2025-01-01T00:00:00Z"
245+
}
246+
247+
# The same token, with validation enabled
248+
$ jwt-cli paseto decode local --key "$KEY" --token "$TOKEN" --validate-claims
249+
decoding failed: claims validation failed: token has expired (exp 2025-01-01T00:00:00Z)
250+
251+
# Accept a token that expired less than five minutes ago
252+
$ jwt-cli paseto decode local --key "$KEY" --token "$TOKEN" --validate-claims --clock-skew 5m
253+
```
254+
255+
Only `exp` and `nbf` are enforced, and only when the claim is present: a token
256+
carrying neither still decodes with `--validate-claims`. `iat` and `aud` are
257+
never enforced.
258+
232259
### JSON output
233260

234261
Like the JWT commands, every PASETO command supports `--json` for programmatic use:

‎cmd/cmd_test_helpers.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,8 @@ func registerPasetoDecodeFlags(cmd *cobra.Command) {
307307
cmd.Flags().String("public-key", "", "path to public key file")
308308
cmd.Flags().String("key", "", "hex-encoded symmetric key")
309309
cmd.Flags().String("version", pasetoV4, "PASETO version")
310+
cmd.Flags().Bool("validate-claims", false, "validate PASETO time-based claims")
311+
cmd.Flags().Duration("clock-skew", 0, "clock skew tolerance for claims validation")
310312
// Deprecated flags for backward compatibility
311313
cmd.Flags().String("t", "", "")
312314
_ = cmd.Flags().MarkDeprecated("t", "use --token or -t instead")

‎cmd/paseto-decode-local.go‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
)
66

77
// createPasetoDecodeLocalCommand builds the "paseto decode local" command.
8+
//
9+
//nolint:funlen // Long help and guidance text dominate this function
810
func createPasetoDecodeLocalCommand() *cobra.Command {
911
return &cobra.Command{
1012
Use: pasetoPurposeLocal,
@@ -15,13 +17,21 @@ The same key used to encode the token is required to decode it.
1517
1618
Claims Validation:
1719
The token's authentication tag is verified, but time-based claims are NOT
18-
validated: an expired token still decodes successfully.`,
20+
validated by default: an expired token still decodes successfully. Pass
21+
--validate-claims to reject expired (exp) or not-yet-valid (nbf) tokens, and
22+
--clock-skew to allow tolerance for clock differences.`,
1923
Example: ` # Decode a v4 local token
2024
jwt-cli paseto decode local --key "$KEY" --token "v4.local.xxxxx"
2125
2226
# Decode a v3 local token
2327
jwt-cli paseto decode local --version v3 --key "$KEY" --token "$TOKEN"
2428
29+
# Reject the token if it has expired
30+
jwt-cli paseto decode local --key "$KEY" --token "$TOKEN" --validate-claims
31+
32+
# Same, tolerating five minutes of clock difference
33+
jwt-cli paseto decode local --key "$KEY" --token "$TOKEN" --validate-claims --clock-skew 5m
34+
2535
# Extract a single claim
2636
jwt-cli paseto decode local --key "$KEY" --token "$TOKEN" | jq -r '.user'`,
2737
RunE: func(cmd *cobra.Command, _ []string) error {
@@ -53,7 +63,7 @@ Example usage:
5363
Tip: The token is the string produced by 'jwt-cli paseto encode local'.`)
5464
}
5565

56-
decoder, err := newLocalCodec(version, key)
66+
decoder, err := newLocalDecoder(cmd, version, key)
5767
if err != nil {
5868
return userError(err.Error())
5969
}

‎cmd/paseto-decode-public.go‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,24 @@ Either the public key (recommended) or the private key may be supplied; when
1717
both are given the public key is used, following asymmetric key practice.
1818
1919
Claims Validation:
20-
The token's signature is verified, but time-based claims are NOT validated:
21-
an expired token still decodes successfully.`,
20+
The token's signature is verified, but time-based claims are NOT validated by
21+
default: an expired token still decodes successfully. Pass --validate-claims
22+
to reject expired (exp) or not-yet-valid (nbf) tokens, and --clock-skew to
23+
allow tolerance for clock differences.`,
2224
Example: ` # Decode with the public key (recommended)
2325
jwt-cli paseto decode public --public-key paseto-v4-public.pem --token "$TOKEN"
2426
2527
# Decode with the private key
2628
jwt-cli paseto decode public --private-key paseto-v4-private.pem --token "$TOKEN"
2729
2830
# Decode a v3 public token (NIST P-384)
29-
jwt-cli paseto decode public --version v3 --public-key paseto-v3-public.pem --token "$TOKEN"`,
31+
jwt-cli paseto decode public --version v3 --public-key paseto-v3-public.pem --token "$TOKEN"
32+
33+
# Reject the token if it has expired
34+
jwt-cli paseto decode public --public-key paseto-v4-public.pem --token "$TOKEN" --validate-claims
35+
36+
# Same, tolerating five minutes of clock difference
37+
jwt-cli paseto decode public --public-key paseto-v4-public.pem --token "$TOKEN" --validate-claims --clock-skew 5m`,
3038
RunE: func(cmd *cobra.Command, _ []string) error {
3139
privateKeyFile := flagWithFallback(cmd, "private-key", "pk")
3240
publicKeyFile := flagWithFallback(cmd, "public-key", "pubk")
@@ -60,7 +68,7 @@ Example usage:
6068
Tip: The token is the string produced by 'jwt-cli paseto encode public'.`)
6169
}
6270

63-
decoder, err := newPublicCodec(version, privateKeyFile, publicKeyFile)
71+
decoder, err := newPublicDecoder(cmd, version, privateKeyFile, publicKeyFile)
6472
if err != nil {
6573
return userError(err.Error())
6674
}

‎cmd/paseto-local_test.go‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package cmd
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"strings"
67
"testing"
8+
"time"
79
)
810

911
// extractToken pulls a PASETO token out of captured command output, which may
@@ -296,3 +298,66 @@ func TestPasetoLocalCommand_JSONErrorEnvelope(t *testing.T) {
296298
t.Error("Expected a non-empty error message in the failure envelope")
297299
}
298300
}
301+
302+
// expiredPasetoPayload renders a payload whose exp claim is already in the past.
303+
func expiredPasetoPayload(age time.Duration) string {
304+
return fmt.Sprintf(`{"name":"John Doe","exp":%d}`, time.Now().Add(-age).Unix())
305+
}
306+
307+
func TestPasetoDecodeLocalCommand_ValidateClaims(t *testing.T) {
308+
token := encodePasetoLocal(t,
309+
"--key", pasetoTestKey,
310+
"--payload", expiredPasetoPayload(time.Hour),
311+
)
312+
313+
t.Run("expired token decodes by default", func(t *testing.T) {
314+
cmd := createPasetoDecodeLocalCommand()
315+
registerPasetoDecodeFlags(cmd)
316+
317+
out, err := executeCommand(cmd, "--key", pasetoTestKey, "--token", token)
318+
if err != nil {
319+
t.Fatalf("Expected the expired token to decode by default, got: %v", err)
320+
}
321+
if !strings.Contains(out, "John Doe") {
322+
t.Errorf("Expected claims in output, got: %s", out)
323+
}
324+
})
325+
326+
t.Run("expired token is rejected with --validate-claims", func(t *testing.T) {
327+
cmd := createPasetoDecodeLocalCommand()
328+
registerPasetoDecodeFlags(cmd)
329+
330+
var err error
331+
stderr := captureStderr(t, func() {
332+
_, err = executeCommand(cmd, "--key", pasetoTestKey, "--token", token, "--validate-claims")
333+
})
334+
if err == nil {
335+
t.Fatal("Expected an error for an expired token, got nil")
336+
}
337+
if !strings.Contains(stderr, "token has expired") {
338+
t.Errorf("Expected the expiry to be reported, got: %s", stderr)
339+
}
340+
})
341+
342+
t.Run("--clock-skew widens the window", func(t *testing.T) {
343+
recent := encodePasetoLocal(t,
344+
"--key", pasetoTestKey,
345+
"--payload", expiredPasetoPayload(time.Minute),
346+
)
347+
348+
cmd := createPasetoDecodeLocalCommand()
349+
registerPasetoDecodeFlags(cmd)
350+
out, err := executeCommand(cmd,
351+
"--key", pasetoTestKey,
352+
"--token", recent,
353+
"--validate-claims",
354+
"--clock-skew", "5m",
355+
)
356+
if err != nil {
357+
t.Fatalf("Expected the token to decode inside the tolerance, got: %v", err)
358+
}
359+
if !strings.Contains(out, "John Doe") {
360+
t.Errorf("Expected claims in output, got: %s", out)
361+
}
362+
})
363+
}

‎cmd/paseto-public_test.go‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"strings"
55
"testing"
6+
"time"
67

78
"github.com/spf13/cobra"
89
)
@@ -282,3 +283,56 @@ func TestPasetoGenkeysCommands(t *testing.T) {
282283
})
283284
}
284285
}
286+
287+
func TestPasetoDecodePublicCommand_ValidateClaims(t *testing.T) {
288+
for _, version := range []string{"v4", "v3", "v2"} {
289+
t.Run(version, func(t *testing.T) {
290+
privateKey, publicKey := pasetoKeyPair(t, version)
291+
292+
encode := createPasetoEncodePublicCommand()
293+
registerPasetoEncodeFlags(encode)
294+
out, err := executeCommand(encode,
295+
"--version", version,
296+
"--private-key", privateKey,
297+
"--payload", expiredPasetoPayload(time.Hour),
298+
)
299+
if err != nil {
300+
t.Fatalf("Failed to encode: %v", err)
301+
}
302+
token := strings.TrimSpace(out)
303+
304+
cmd := createPasetoDecodePublicCommand()
305+
registerPasetoDecodeFlags(cmd)
306+
stderr := captureStderr(t, func() {
307+
_, err = executeCommand(cmd,
308+
"--version", version,
309+
"--public-key", publicKey,
310+
"--token", token,
311+
"--validate-claims",
312+
)
313+
})
314+
if err == nil {
315+
t.Fatal("Expected an error for an expired token, got nil")
316+
}
317+
if !strings.Contains(stderr, "token has expired") {
318+
t.Errorf("Expected the expiry to be reported, got: %s", stderr)
319+
}
320+
321+
cmd = createPasetoDecodePublicCommand()
322+
registerPasetoDecodeFlags(cmd)
323+
out, err = executeCommand(cmd,
324+
"--version", version,
325+
"--public-key", publicKey,
326+
"--token", token,
327+
"--validate-claims",
328+
"--clock-skew", "24h",
329+
)
330+
if err != nil {
331+
t.Fatalf("Expected the token to decode inside the tolerance, got: %v", err)
332+
}
333+
if !strings.Contains(out, "John Doe") {
334+
t.Errorf("Expected claims in output, got: %s", out)
335+
}
336+
})
337+
}
338+
}

‎cmd/paseto.go‎

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ Token purposes:
4646
4747
Claims Validation:
4848
Decoding verifies the token's signature or authentication tag, but does NOT
49-
validate time-based claims: expired (exp) and not-yet-valid (nbf) tokens are
50-
decoded successfully so their contents can be inspected.`,
49+
validate time-based claims by default: expired (exp) and not-yet-valid (nbf)
50+
tokens are decoded successfully so their contents can be inspected. Pass
51+
--validate-claims to "paseto decode" to enforce them instead.`,
5152
Example: ` # Generate a symmetric key and encode a local token
5253
jwt-cli paseto encode local --key "$(openssl rand -hex 32)" --payload '{"user":"alice"}'
5354
@@ -70,7 +71,10 @@ var pasetoDecodeCmd = &cobra.Command{
7071
Short: "Decode a PASETO token",
7172
Long: `Decode and verify a PASETO local (symmetric) or public (asymmetric) token.
7273
73-
Time-based claims are not validated: an expired token still decodes.`,
74+
Time-based claims are not validated by default: an expired token still decodes.
75+
Use --validate-claims to reject expired (exp) or not-yet-valid (nbf) tokens, and
76+
--clock-skew to allow tolerance for clock differences. A claim that is absent is
77+
never enforced, and iat is never enforced.`,
7478
ValidArgs: []string{pasetoPurposeLocal, pasetoPurposePublic},
7579
}
7680

@@ -115,18 +119,18 @@ Tip: v4 is the recommended default. Use v3 only when a NIST-approved
115119
}
116120

117121
// newLocalCodec builds an encoder/decoder for PASETO local (symmetric) tokens.
118-
func newLocalCodec(version, keyHex string) (paseto.EncoderDecoder, error) {
122+
func newLocalCodec(version, keyHex string, opts ...paseto.Option) (paseto.EncoderDecoder, error) {
119123
var (
120124
codec paseto.EncoderDecoder
121125
err error
122126
)
123127
switch version {
124128
case pasetoV4:
125-
codec, err = paseto.NewLocalV4Encoder(keyHex)
129+
codec, err = paseto.NewLocalV4Encoder(keyHex, opts...)
126130
case pasetoV3:
127-
codec, err = paseto.NewLocalV3Encoder(keyHex)
131+
codec, err = paseto.NewLocalV3Encoder(keyHex, opts...)
128132
case pasetoV2:
129-
codec, err = paseto.NewLocalV2Encoder(keyHex)
133+
codec, err = paseto.NewLocalV2Encoder(keyHex, opts...)
130134
default:
131135
return nil, errUnsupportedPasetoVersion(version)
132136
}
@@ -142,7 +146,7 @@ func newLocalCodec(version, keyHex string) (paseto.EncoderDecoder, error) {
142146
// When a public key file is supplied it takes precedence, matching the JWT
143147
// decode commands; the resulting value can only decode. Supplying a private key
144148
// yields a value that can both sign and verify.
145-
func newPublicCodec(version, privateKeyFile, publicKeyFile string) (paseto.EncoderDecoder, error) {
149+
func newPublicCodec(version, privateKeyFile, publicKeyFile string, opts ...paseto.Option) (paseto.EncoderDecoder, error) {
146150
var (
147151
codec paseto.EncoderDecoder
148152
err error
@@ -151,21 +155,21 @@ func newPublicCodec(version, privateKeyFile, publicKeyFile string) (paseto.Encod
151155
switch version {
152156
case pasetoV4:
153157
if usePublicKey {
154-
codec, err = paseto.NewPublicV4DecoderFromPublicKey(publicKeyFile)
158+
codec, err = paseto.NewPublicV4DecoderFromPublicKey(publicKeyFile, opts...)
155159
} else {
156-
codec, err = paseto.NewPublicV4EncoderFromPrivateKey(privateKeyFile)
160+
codec, err = paseto.NewPublicV4EncoderFromPrivateKey(privateKeyFile, opts...)
157161
}
158162
case pasetoV3:
159163
if usePublicKey {
160-
codec, err = paseto.NewPublicV3DecoderFromPublicKey(publicKeyFile)
164+
codec, err = paseto.NewPublicV3DecoderFromPublicKey(publicKeyFile, opts...)
161165
} else {
162-
codec, err = paseto.NewPublicV3EncoderFromPrivateKey(privateKeyFile)
166+
codec, err = paseto.NewPublicV3EncoderFromPrivateKey(privateKeyFile, opts...)
163167
}
164168
case pasetoV2:
165169
if usePublicKey {
166-
codec, err = paseto.NewPublicV2DecoderFromPublicKey(publicKeyFile)
170+
codec, err = paseto.NewPublicV2DecoderFromPublicKey(publicKeyFile, opts...)
167171
} else {
168-
codec, err = paseto.NewPublicV2EncoderFromPrivateKey(privateKeyFile)
172+
codec, err = paseto.NewPublicV2EncoderFromPrivateKey(privateKeyFile, opts...)
169173
}
170174
default:
171175
return nil, errUnsupportedPasetoVersion(version)
@@ -184,3 +188,29 @@ func pasetoVersionFlag(cmd *cobra.Command) string {
184188
}
185189
return version
186190
}
191+
192+
// pasetoValidationOption reads the claims-validation flags shared by the paseto
193+
// decode subcommands.
194+
//
195+
// Validation is off by default, so a token can always be inspected regardless
196+
// of its timing claims; --clock-skew only matters once --validate-claims is on.
197+
func pasetoValidationOption(cmd *cobra.Command) paseto.Option {
198+
validateClaims, _ := cmd.Flags().GetBool("validate-claims")
199+
clockSkew, _ := cmd.Flags().GetDuration("clock-skew")
200+
return paseto.WithValidation(paseto.ValidationOptions{
201+
ValidateClaims: validateClaims,
202+
ClockSkew: clockSkew,
203+
})
204+
}
205+
206+
// newLocalDecoder builds a decoder for local tokens, honouring the claims
207+
// validation flags.
208+
func newLocalDecoder(cmd *cobra.Command, version, keyHex string) (paseto.EncoderDecoder, error) {
209+
return newLocalCodec(version, keyHex, pasetoValidationOption(cmd))
210+
}
211+
212+
// newPublicDecoder builds a decoder for public tokens, honouring the claims
213+
// validation flags.
214+
func newPublicDecoder(cmd *cobra.Command, version, privateKeyFile, publicKeyFile string) (paseto.EncoderDecoder, error) {
215+
return newPublicCodec(version, privateKeyFile, publicKeyFile, pasetoValidationOption(cmd))
216+
}

‎cmd/root.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ func init() {
176176
"hex-encoded 32-byte symmetric key for local tokens")
177177
pasetoDecodeCmd.PersistentFlags().String("version", pasetoV4,
178178
"PASETO version (v2, v3, v4)")
179+
pasetoDecodeCmd.PersistentFlags().Bool("validate-claims", false,
180+
"validate PASETO time-based claims (exp, nbf) - reject expired or not-yet-valid tokens")
181+
pasetoDecodeCmd.PersistentFlags().Duration("clock-skew", 0,
182+
"clock skew tolerance for claims validation (e.g., 5m, 30s)")
179183
_ = pasetoDecodeCmd.MarkPersistentFlagFilename("private-key", "pem", "key")
180184
_ = pasetoDecodeCmd.MarkPersistentFlagFilename("public-key", "pem", "key")
181185
_ = pasetoDecodeCmd.MarkPersistentFlagFilename("token", "paseto", "txt")

0 commit comments

Comments
 (0)