golib/auth/jwt/tests/nuance/nuance_report_test.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)
}