mirror of
https://github.com/therootcompany/golib.git
synced 2026-03-29 03:24:07 +00:00
526 lines
18 KiB
Go
526 lines
18 KiB
Go
// Package nuance_test documents behavioral differences between this library,
|
|
// go-jose/go-jose v4, and lestrrat-go/jwx v3 that may cause interop surprises.
|
|
//
|
|
// Each test logs observations via t.Log so that `go test -v ./nuance/` produces
|
|
// a readable report. Tests that demonstrate library-specific defaults use
|
|
// controlled clock offsets to show exactly where each library draws the line.
|
|
//
|
|
// Run:
|
|
//
|
|
// go test ./nuance/ -v
|
|
package nuance_test
|
|
|
|
import (
|
|
"crypto"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
jose "github.com/go-jose/go-jose/v4"
|
|
josejwt "github.com/go-jose/go-jose/v4/jwt"
|
|
"github.com/lestrrat-go/jwx/v3/jwa"
|
|
jwxjwk "github.com/lestrrat-go/jwx/v3/jwk"
|
|
jwxjwt "github.com/lestrrat-go/jwx/v3/jwt"
|
|
|
|
"github.com/therootcompany/golib/auth/jwt"
|
|
"github.com/therootcompany/golib/auth/jwt/tests/testkeys"
|
|
)
|
|
|
|
// signOurs creates a JWT signed with our library using the given claims.
|
|
func signOurs(t *testing.T, ks testkeys.KeySet, claims jwt.Claims) string {
|
|
t.Helper()
|
|
signer, err := jwt.NewSigner([]*jwt.PrivateKey{ks.PrivKey})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok, err := signer.SignToString(claims)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tok
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Clock skew / expiration tolerance
|
|
// -----------------------------------------------------------------------
|
|
|
|
func TestNuance_ClockSkew_GoJose(t *testing.T) {
|
|
t.Log("=== Nuance: expiration checking - when does it happen? ===")
|
|
t.Log("")
|
|
t.Log("CRITICAL: Our VerifyJWT only checks the SIGNATURE.")
|
|
t.Log("Claims validation (exp, iat) requires a separate Validate() call.")
|
|
t.Log("go-jose also separates verification from validation.")
|
|
t.Log("jwx bundles both into jwt.Parse by default.")
|
|
t.Log("")
|
|
t.Log("go-jose ValidateWithLeeway takes an explicit leeway parameter.")
|
|
t.Log("Our Validator.Validate uses DefaultGracePeriod (2s).")
|
|
t.Log("")
|
|
|
|
ks := testkeys.GenerateEdDSA("skew")
|
|
|
|
// Token expired 30 seconds ago.
|
|
claims := &jwt.TokenClaims{
|
|
Iss: "https://example.com",
|
|
Sub: "skew-test",
|
|
Exp: time.Now().Add(-30 * time.Second).Unix(),
|
|
IAt: time.Now().Add(-5 * time.Minute).Unix(),
|
|
}
|
|
tokenStr := signOurs(t, ks, claims)
|
|
|
|
// Our VerifyJWT: signature-only, does NOT check exp.
|
|
verifier, _ := jwt.NewVerifier([]jwt.PublicKey{ks.PubKey})
|
|
jws, ourSigErr := verifier.VerifyJWT(tokenStr)
|
|
t.Logf(" our VerifyJWT (sig only): accepts=%v", ourSigErr == nil)
|
|
|
|
// Our Validate: checks exp with DefaultGracePeriod (2s) => REJECTS.
|
|
if jws != nil {
|
|
var decoded jwt.TokenClaims
|
|
jws.UnmarshalClaims(&decoded)
|
|
v := jwt.Validator{
|
|
Checks: jwt.CheckIss | jwt.CheckExp | jwt.CheckIAt | jwt.CheckNBf,
|
|
Iss: []string{"https://example.com"},
|
|
}
|
|
valErr := v.Validate(nil, &decoded, time.Now())
|
|
t.Logf(" our Validate (2s grace): rejects=%v (err=%v)",
|
|
valErr != nil, valErr)
|
|
if valErr == nil {
|
|
t.Error("expected our Validate to reject a token expired 30s ago")
|
|
}
|
|
}
|
|
|
|
// go-jose: also separates parse/verify from validation.
|
|
tok, _ := josejwt.ParseSigned(tokenStr, []jose.SignatureAlgorithm{jose.EdDSA})
|
|
var joseClaims josejwt.Claims
|
|
tok.Claims(ks.RawPub, &joseClaims)
|
|
|
|
// go-jose with explicit 1-minute leeway => accepts.
|
|
err1m := joseClaims.ValidateWithLeeway(josejwt.Expected{Time: time.Now()}, 1*time.Minute)
|
|
t.Logf(" go-jose (1m leeway): accepts=%v", err1m == nil)
|
|
|
|
// go-jose with 0 leeway => rejects.
|
|
err0 := joseClaims.ValidateWithLeeway(josejwt.Expected{Time: time.Now()}, 0)
|
|
t.Logf(" go-jose (0 leeway): rejects=%v", err0 != nil)
|
|
|
|
if err1m != nil {
|
|
t.Error("expected go-jose to accept with 1m leeway")
|
|
}
|
|
if err0 == nil {
|
|
t.Error("expected go-jose to reject with 0 leeway")
|
|
}
|
|
|
|
t.Log("")
|
|
t.Log("ACTION: Our VerifyJWT is signature-only. You MUST call Validate()")
|
|
t.Log("after VerifyJWT to enforce exp/iat. go-jose likewise requires an")
|
|
t.Log("explicit ValidateWithLeeway call. Choose matching leeway values.")
|
|
}
|
|
|
|
func TestNuance_ClockSkew_JWX(t *testing.T) {
|
|
t.Log("=== Nuance: jwx bundles validation into jwt.Parse ===")
|
|
t.Log("")
|
|
t.Log("Unlike our lib and go-jose (which separate sig from claims),")
|
|
t.Log("jwx v3 validates exp/iat DURING jwt.Parse. Default skew is 0.")
|
|
t.Log("Use jwt.WithAcceptableSkew(d) or jwt.WithValidate(false) to adjust.")
|
|
t.Log("")
|
|
|
|
ks := testkeys.GenerateEdDSA("skew-jwx")
|
|
|
|
// Token expired 1 second ago.
|
|
claims := &jwt.TokenClaims{
|
|
Iss: "https://example.com",
|
|
Sub: "skew-test",
|
|
Exp: time.Now().Add(-1 * time.Second).Unix(),
|
|
IAt: time.Now().Add(-5 * time.Minute).Unix(),
|
|
}
|
|
tokenStr := signOurs(t, ks, claims)
|
|
|
|
// jwx: zero skew (default) => rejects at parse time.
|
|
_, jwxErr := jwxjwt.Parse([]byte(tokenStr), jwxjwt.WithKey(jwa.EdDSA(), ks.RawPub))
|
|
t.Logf(" jwx Parse (0s skew): rejects=%v", jwxErr != nil)
|
|
|
|
// jwx: with 5s skew => accepts.
|
|
_, jwxErr5 := jwxjwt.Parse([]byte(tokenStr),
|
|
jwxjwt.WithKey(jwa.EdDSA(), ks.RawPub),
|
|
jwxjwt.WithAcceptableSkew(5*time.Second),
|
|
)
|
|
t.Logf(" jwx Parse (5s skew): accepts=%v", jwxErr5 == nil)
|
|
|
|
// jwx: validation disabled => accepts (sig-only, like our VerifyJWT).
|
|
_, jwxErrNoval := jwxjwt.Parse([]byte(tokenStr),
|
|
jwxjwt.WithKey(jwa.EdDSA(), ks.RawPub),
|
|
jwxjwt.WithValidate(false),
|
|
)
|
|
t.Logf(" jwx Parse (no validate): accepts=%v", jwxErrNoval == nil)
|
|
|
|
// Our VerifyJWT: always accepts (sig-only).
|
|
verifier, _ := jwt.NewVerifier([]jwt.PublicKey{ks.PubKey})
|
|
_, ourErr := verifier.VerifyJWT(tokenStr)
|
|
t.Logf(" our VerifyJWT (sig only): accepts=%v", ourErr == nil)
|
|
|
|
if jwxErr == nil {
|
|
t.Error("expected jwx to reject with 0 skew")
|
|
}
|
|
if jwxErr5 != nil {
|
|
t.Error("expected jwx to accept with 5s skew")
|
|
}
|
|
if jwxErrNoval != nil {
|
|
t.Error("expected jwx to accept with validation disabled")
|
|
}
|
|
if ourErr != nil {
|
|
t.Errorf("expected our VerifyJWT to accept (sig-only): %v", ourErr)
|
|
}
|
|
|
|
t.Log("")
|
|
t.Log("ACTION: jwx rejects expired tokens at parse time. Use")
|
|
t.Log("WithAcceptableSkew(d) to add clock tolerance, or")
|
|
t.Log("WithValidate(false) for sig-only (matching our VerifyJWT).")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// kid header emission
|
|
// -----------------------------------------------------------------------
|
|
|
|
func TestNuance_KIDHeader_GoJose(t *testing.T) {
|
|
t.Log("=== Nuance: go-jose kid header emission ===")
|
|
t.Log("")
|
|
t.Log("go-jose omits 'kid' from the JWS header unless:")
|
|
t.Log(" 1. The signing key is wrapped in jose.JSONWebKey{KeyID: ...}, or")
|
|
t.Log(" 2. opts.WithHeader(jose.HeaderKey(\"kid\"), ...) is used.")
|
|
t.Log("Our verifier tries all keys when kid is missing (fallback).")
|
|
t.Log("")
|
|
|
|
ks := testkeys.GenerateEdDSA("kid-test")
|
|
|
|
// Sign with raw key (no JSONWebKey wrapper) - kid is missing.
|
|
rawSigKey := jose.SigningKey{
|
|
Algorithm: jose.EdDSA,
|
|
Key: ks.RawPriv, // raw key, no JSONWebKey wrapper
|
|
}
|
|
rawSigner, _ := jose.NewSigner(rawSigKey, nil)
|
|
rawClaims := josejwt.Claims{
|
|
Subject: "raw-key",
|
|
Expiry: josejwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
}
|
|
rawToken, _ := josejwt.Signed(rawSigner).Claims(rawClaims).Serialize()
|
|
|
|
// Check the header.
|
|
parts := strings.SplitN(rawToken, ".", 3)
|
|
headerJSON, _ := base64.RawURLEncoding.DecodeString(parts[0])
|
|
var header map[string]any
|
|
json.Unmarshal(headerJSON, &header)
|
|
_, hasKID := header["kid"]
|
|
t.Logf(" raw key signing: kid in header = %v (header: %s)", hasKID, headerJSON)
|
|
|
|
// Our verifier accepts via try-all-keys fallback (no kid => try every key).
|
|
verifier, _ := jwt.NewVerifier([]jwt.PublicKey{ks.PubKey})
|
|
_, ourErr := verifier.VerifyJWT(rawToken)
|
|
t.Logf(" our VerifyJWT: err = %v", ourErr)
|
|
|
|
// Sign with JSONWebKey wrapper - kid is present.
|
|
wrappedSigKey := jose.SigningKey{
|
|
Algorithm: jose.EdDSA,
|
|
Key: jose.JSONWebKey{Key: ks.RawPriv, KeyID: ks.KID},
|
|
}
|
|
wrappedSigner, _ := jose.NewSigner(wrappedSigKey, nil)
|
|
wrappedToken, _ := josejwt.Signed(wrappedSigner).Claims(rawClaims).Serialize()
|
|
parts2 := strings.SplitN(wrappedToken, ".", 3)
|
|
headerJSON2, _ := base64.RawURLEncoding.DecodeString(parts2[0])
|
|
var header2 map[string]any
|
|
json.Unmarshal(headerJSON2, &header2)
|
|
_, hasKID2 := header2["kid"]
|
|
t.Logf(" JSONWebKey signing: kid in header = %v (header: %s)", hasKID2, headerJSON2)
|
|
|
|
_, ourErr2 := verifier.VerifyJWT(wrappedToken)
|
|
t.Logf(" our VerifyJWT: err = %v", ourErr2)
|
|
|
|
if hasKID {
|
|
t.Error("expected raw key signing to NOT have kid in header")
|
|
}
|
|
if ourErr != nil {
|
|
t.Errorf("expected our verifier to accept token without kid (try-all-keys fallback), got: %v", ourErr)
|
|
}
|
|
if !hasKID2 {
|
|
t.Error("expected JSONWebKey signing to have kid in header")
|
|
}
|
|
if ourErr2 != nil {
|
|
t.Errorf("expected our verifier to accept token with kid, got: %v", ourErr2)
|
|
}
|
|
|
|
t.Log("")
|
|
t.Log("NOTE: When kid is missing, our verifier tries all keys (first match wins).")
|
|
t.Log("For multi-key verifiers, always set kid for efficient key lookup.")
|
|
}
|
|
|
|
func TestNuance_KIDHeader_JWX(t *testing.T) {
|
|
t.Log("=== Nuance: jwx kid header emission ===")
|
|
t.Log("")
|
|
t.Log("jwx omits 'kid' unless jwk.KeyIDKey is set on the key before signing.")
|
|
t.Log("Our verifier tries all keys when kid is missing (fallback).")
|
|
t.Log("")
|
|
|
|
ks := testkeys.GenerateEdDSA("kid-jwx")
|
|
|
|
// Import key WITHOUT setting kid.
|
|
jwxKeyNoKID, _ := jwxjwk.Import(ks.RawPriv)
|
|
tok := jwxjwt.New()
|
|
tok.Set(jwxjwt.SubjectKey, "no-kid")
|
|
tok.Set(jwxjwt.ExpirationKey, time.Now().Add(time.Hour))
|
|
noKIDToken, _ := jwxjwt.Sign(tok, jwxjwt.WithKey(jwa.EdDSA(), jwxKeyNoKID))
|
|
|
|
parts := strings.SplitN(string(noKIDToken), ".", 3)
|
|
headerJSON, _ := base64.RawURLEncoding.DecodeString(parts[0])
|
|
var header map[string]any
|
|
json.Unmarshal(headerJSON, &header)
|
|
_, hasKID := header["kid"]
|
|
t.Logf(" no KeyIDKey set: kid in header = %v (header: %s)", hasKID, headerJSON)
|
|
|
|
verifier, _ := jwt.NewVerifier([]jwt.PublicKey{ks.PubKey})
|
|
_, ourErr := verifier.VerifyJWT(string(noKIDToken))
|
|
t.Logf(" our VerifyJWT: err = %v", ourErr)
|
|
|
|
// Import key WITH kid set.
|
|
jwxKeyWithKID, _ := jwxjwk.Import(ks.RawPriv)
|
|
jwxKeyWithKID.Set(jwxjwk.KeyIDKey, ks.KID)
|
|
tok2 := jwxjwt.New()
|
|
tok2.Set(jwxjwt.SubjectKey, "with-kid")
|
|
tok2.Set(jwxjwt.ExpirationKey, time.Now().Add(time.Hour))
|
|
withKIDToken, _ := jwxjwt.Sign(tok2, jwxjwt.WithKey(jwa.EdDSA(), jwxKeyWithKID))
|
|
|
|
parts2 := strings.SplitN(string(withKIDToken), ".", 3)
|
|
headerJSON2, _ := base64.RawURLEncoding.DecodeString(parts2[0])
|
|
var header2 map[string]any
|
|
json.Unmarshal(headerJSON2, &header2)
|
|
_, hasKID2 := header2["kid"]
|
|
t.Logf(" KeyIDKey set: kid in header = %v (header: %s)", hasKID2, headerJSON2)
|
|
|
|
_, ourErr2 := verifier.VerifyJWT(string(withKIDToken))
|
|
t.Logf(" our VerifyJWT: err = %v", ourErr2)
|
|
|
|
if hasKID {
|
|
t.Error("expected no-kid key to omit kid from header")
|
|
}
|
|
if ourErr != nil {
|
|
t.Errorf("expected our verifier to accept token without kid (try-all-keys fallback), got: %v", ourErr)
|
|
}
|
|
if !hasKID2 {
|
|
t.Error("expected kid-set key to include kid in header")
|
|
}
|
|
if ourErr2 != nil {
|
|
t.Errorf("expected our verifier to accept token with kid, got: %v", ourErr2)
|
|
}
|
|
|
|
t.Log("")
|
|
t.Log("NOTE: When kid is missing, our verifier tries all keys (first match wins).")
|
|
t.Log("For multi-key verifiers, always set kid for efficient key lookup.")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Audience marshaling
|
|
// -----------------------------------------------------------------------
|
|
|
|
func TestNuance_ListishMarshal(t *testing.T) {
|
|
t.Log("=== Nuance: audience JSON marshaling ===")
|
|
t.Log("")
|
|
t.Log("RFC 7519 allows aud as either a string or an array of strings.")
|
|
t.Log("Libraries differ in how they marshal a single-value audience:")
|
|
t.Log("")
|
|
|
|
ks := testkeys.GenerateEdDSA("aud-marshal")
|
|
|
|
// Our library: single aud => string, multi aud => array.
|
|
singleClaims := testkeys.ListishClaims("aud-test", jwt.Listish{"single"})
|
|
signer, _ := jwt.NewSigner([]*jwt.PrivateKey{ks.PrivKey})
|
|
ourSingleTok, _ := signer.SignToString(singleClaims)
|
|
ourSinglePayload := decodePayload(ourSingleTok)
|
|
t.Logf(" our lib (single aud): %s", ourSinglePayload)
|
|
|
|
multiClaims := testkeys.ListishClaims("aud-test", jwt.Listish{"a", "b"})
|
|
ourMultiTok, _ := signer.SignToString(multiClaims)
|
|
ourMultiPayload := decodePayload(ourMultiTok)
|
|
t.Logf(" our lib (multi aud): %s", ourMultiPayload)
|
|
|
|
// go-jose: check how it marshals.
|
|
sigKey := jose.SigningKey{
|
|
Algorithm: jose.EdDSA,
|
|
Key: jose.JSONWebKey{Key: ks.RawPriv, KeyID: ks.KID},
|
|
}
|
|
joseSigner, _ := jose.NewSigner(sigKey, nil)
|
|
joseSingleClaims := josejwt.Claims{
|
|
Subject: "aud-test",
|
|
Audience: []string{"single"},
|
|
Expiry: josejwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
}
|
|
joseSingleTok, _ := josejwt.Signed(joseSigner).Claims(joseSingleClaims).Serialize()
|
|
joseSinglePayload := decodePayload(joseSingleTok)
|
|
t.Logf(" go-jose (single aud): %s", joseSinglePayload)
|
|
|
|
joseMultiClaims := josejwt.Claims{
|
|
Subject: "aud-test",
|
|
Audience: []string{"a", "b"},
|
|
Expiry: josejwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
}
|
|
joseMultiTok, _ := josejwt.Signed(joseSigner).Claims(joseMultiClaims).Serialize()
|
|
joseMultiPayload := decodePayload(joseMultiTok)
|
|
t.Logf(" go-jose (multi aud): %s", joseMultiPayload)
|
|
|
|
// All parsers should handle both string and array forms.
|
|
// Verify our parser handles go-jose's format.
|
|
verifier, _ := jwt.NewVerifier([]jwt.PublicKey{ks.PubKey})
|
|
verifiedJWS, err := verifier.VerifyJWT(joseSingleTok)
|
|
if err != nil {
|
|
t.Fatalf("our verify of go-jose single aud: %v", err)
|
|
}
|
|
var decoded jwt.TokenClaims
|
|
verifiedJWS.UnmarshalClaims(&decoded)
|
|
t.Logf(" our parse of go-jose single aud: %v", decoded.Aud)
|
|
|
|
if len(decoded.Aud) != 1 || decoded.Aud[0] != "single" {
|
|
t.Errorf("expected [single], got %v", decoded.Aud)
|
|
}
|
|
|
|
t.Log("")
|
|
t.Log("Both libraries handle both string and array forms on input.")
|
|
t.Log("No action needed - interop is seamless for audience values.")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Thumbprint encoding
|
|
// -----------------------------------------------------------------------
|
|
|
|
func TestNuance_ThumbprintEncoding(t *testing.T) {
|
|
t.Log("=== Nuance: JWK Thumbprint encoding (RFC 7638) ===")
|
|
t.Log("")
|
|
t.Log("All 3 libraries use unpadded base64url encoding for thumbprints.")
|
|
t.Log("Confirming no library adds '=' padding:")
|
|
t.Log("")
|
|
|
|
for _, ag := range testkeys.AllAlgorithms() {
|
|
ks := ag.Generate("thumb-enc-" + ag.Name)
|
|
|
|
// Our thumbprint.
|
|
ourThumb, _ := ks.PubKey.Thumbprint()
|
|
hasPadding := strings.Contains(ourThumb, "=")
|
|
t.Logf(" %s - our thumbprint: %s (padding=%v)", ag.Name, ourThumb, hasPadding)
|
|
if hasPadding {
|
|
t.Errorf("%s: our thumbprint has padding", ag.Name)
|
|
}
|
|
|
|
// go-jose thumbprint.
|
|
joseKey := jose.JSONWebKey{Key: ks.RawPub}
|
|
joseRaw, _ := joseKey.Thumbprint(crypto.SHA256)
|
|
joseThumb := base64.RawURLEncoding.EncodeToString(joseRaw)
|
|
t.Logf(" %s - go-jose thumbprint: %s", ag.Name, joseThumb)
|
|
|
|
// jwx thumbprint.
|
|
jwxKey, _ := jwxjwk.Import(ks.RawPub)
|
|
jwxRaw, _ := jwxKey.Thumbprint(crypto.SHA256)
|
|
jwxThumb := base64.RawURLEncoding.EncodeToString(jwxRaw)
|
|
t.Logf(" %s - jwx thumbprint: %s", ag.Name, jwxThumb)
|
|
|
|
// All three should match.
|
|
if ourThumb != joseThumb || ourThumb != jwxThumb {
|
|
t.Errorf("%s: thumbprint mismatch: ours=%s go-jose=%s jwx=%s",
|
|
ag.Name, ourThumb, joseThumb, jwxThumb)
|
|
}
|
|
}
|
|
|
|
t.Log("")
|
|
t.Log("All 3 libraries produce identical unpadded base64url thumbprints.")
|
|
t.Log("No action needed.")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// iat (issued-at) validation
|
|
// -----------------------------------------------------------------------
|
|
|
|
func TestNuance_IssuedAtValidation(t *testing.T) {
|
|
t.Log("=== Nuance: iat (issued-at) validation ===")
|
|
t.Log("")
|
|
t.Log("All three libraries reject future iat. Our library checks that iat,")
|
|
t.Log("when present, is not in the future - a common-sense sanity check")
|
|
t.Log("even though the spec does not require it.")
|
|
t.Log("")
|
|
|
|
ks := testkeys.GenerateEdDSA("iat-test")
|
|
|
|
// Token with iat 10 seconds in the future.
|
|
claims := &jwt.TokenClaims{
|
|
Iss: "https://example.com",
|
|
Sub: "iat-future",
|
|
Exp: time.Now().Add(time.Hour).Unix(),
|
|
IAt: time.Now().Add(10 * time.Second).Unix(),
|
|
}
|
|
tokenStr := signOurs(t, ks, claims)
|
|
|
|
// jwx: rejects at parse time (iat in future, 0 skew).
|
|
_, jwxErr := jwxjwt.Parse([]byte(tokenStr), jwxjwt.WithKey(jwa.EdDSA(), ks.RawPub))
|
|
t.Logf(" jwx Parse (0 skew): rejects=%v", jwxErr != nil)
|
|
|
|
// go-jose: parse+verify succeeds, ValidateWithLeeway rejects future iat.
|
|
tok, _ := josejwt.ParseSigned(tokenStr, []jose.SignatureAlgorithm{jose.EdDSA})
|
|
var joseClaims josejwt.Claims
|
|
tok.Claims(ks.RawPub, &joseClaims)
|
|
joseErr := joseClaims.ValidateWithLeeway(josejwt.Expected{Time: time.Now()}, 0)
|
|
t.Logf(" go-jose ValidateWithLeeway(0): rejects=%v", joseErr != nil)
|
|
|
|
// Our VerifyJWT: accepts (signature-only, no iat check).
|
|
verifier, _ := jwt.NewVerifier([]jwt.PublicKey{ks.PubKey})
|
|
jws, ourSigErr := verifier.VerifyJWT(tokenStr)
|
|
t.Logf(" our VerifyJWT (sig only): accepts=%v", ourSigErr == nil)
|
|
|
|
// Our Validate: rejects future iat (common-sense check, not per spec).
|
|
if jws != nil {
|
|
var decoded jwt.TokenClaims
|
|
jws.UnmarshalClaims(&decoded)
|
|
v := jwt.Validator{
|
|
Checks: jwt.CheckIss | jwt.CheckExp | jwt.CheckIAt | jwt.CheckNBf,
|
|
Iss: []string{"https://example.com"},
|
|
}
|
|
valErr := v.Validate(nil, &decoded, time.Now())
|
|
t.Logf(" our Validate: rejects=%v", valErr != nil)
|
|
if valErr == nil {
|
|
t.Error("expected our Validate to reject future iat")
|
|
}
|
|
}
|
|
|
|
if jwxErr == nil {
|
|
t.Error("expected jwx to reject future iat")
|
|
}
|
|
if joseErr == nil {
|
|
t.Error("expected go-jose to reject future iat")
|
|
}
|
|
if ourSigErr != nil {
|
|
t.Errorf("expected our VerifyJWT to accept (sig-only): %v", ourSigErr)
|
|
}
|
|
|
|
t.Log("")
|
|
t.Log("All three libraries agree: future iat is rejected.")
|
|
t.Log("Remove CheckIAt from Checks to opt out of this check if needed.")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// helpers
|
|
// -----------------------------------------------------------------------
|
|
|
|
func decodePayload(tokenStr string) string {
|
|
parts := strings.SplitN(tokenStr, ".", 3)
|
|
if len(parts) < 2 {
|
|
return "(invalid token)"
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return "(decode error: " + err.Error() + ")"
|
|
}
|
|
|
|
// Extract just the aud field for compact display.
|
|
var m map[string]any
|
|
json.Unmarshal(payload, &m)
|
|
aud, ok := m["aud"]
|
|
if !ok {
|
|
return "(no aud field)"
|
|
}
|
|
audJSON, _ := json.Marshal(aud)
|
|
return "aud=" + string(audJSON)
|
|
}
|