Files
jwt/jwt_test.go
zeekay 899abd0f1d hanzoai/jwt: shared JWT sign/verify/JWKS for the Hanzo platform
One canonical Claims + User, one Signer (RS256/RS512/ES256/384/512/MLDSA65,
default RS256), one Parser/Verifier (cert + JWKS paths), one JWKS builder —
imported by iam2 (mints), cloud + gateway (validate), collapsing three
hand-synced JWT-validator copies into one. Clean leaf (go-jose + golang-jwt +
luxfi/crypto/mldsa only; zero hanzoai/* imports, no cycle). Ported the ML-DSA-65
signing method + Claims shape from hanzoai/iam v1 with field parity on every
validated field (owner/scope/project/azp/aud/iss/sub/email). 9/9 tests green
incl RS256+MLDSA65 round-trip, JWKS, and audience/issuer/expiry/wrong-key
rejection.
2026-07-14 22:01:37 -07:00

306 lines
7.9 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package jwt_test
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"testing"
"time"
jwt "github.com/hanzoai/jwt"
)
// sampleClaims is the fixture every round-trip asserts survives mint→verify: it
// exercises the load-bearing fields cloud and the gateway key on — Owner (home
// org), Scope, Project, Azp, aud and iss — plus identity and token metadata.
func sampleClaims() *jwt.Claims {
now := time.Now()
return &jwt.Claims{
User: &jwt.User{
Owner: "acme",
Name: "alice",
Id: "u-123",
DisplayName: "Alice",
Avatar: "https://cdn.example/a.png",
Email: "[email protected]",
EmailVerified: true,
Phone: "+15551234567",
},
TokenType: "access-token",
Nonce: "n-once",
Tag: "tag-1",
Scope: "openid profile email",
Azp: "hanzo-console",
Provider: "hanzo",
Project: "proj-x",
SigninMethod: "password",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://hanzo.id",
Subject: "u-123",
Audience: jwt.ClaimStrings{"hanzo-console"},
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
NotBefore: jwt.NewNumericDate(now.Add(-time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
ID: "jti-1",
},
}
}
// assertClaims verifies the fields whose parity the whole package exists to
// guarantee are present and correct after a verify.
func assertClaims(t *testing.T, got *jwt.Claims) {
t.Helper()
if got.User == nil {
t.Fatal("embedded user is nil after parse")
}
if got.Owner != "acme" {
t.Errorf("owner = %q, want acme", got.Owner)
}
if got.Scope != "openid profile email" {
t.Errorf("scope = %q, want openid profile email", got.Scope)
}
if got.Project != "proj-x" {
t.Errorf("project = %q, want proj-x", got.Project)
}
if got.Azp != "hanzo-console" {
t.Errorf("azp = %q, want hanzo-console", got.Azp)
}
if got.TokenType != "access-token" {
t.Errorf("tokenType = %q, want access-token", got.TokenType)
}
if got.SigninMethod != "password" {
t.Errorf("signinMethod = %q, want password", got.SigninMethod)
}
if got.Issuer != "https://hanzo.id" {
t.Errorf("iss = %q, want https://hanzo.id", got.Issuer)
}
if got.Subject != "u-123" {
t.Errorf("sub = %q, want u-123", got.Subject)
}
if len(got.Audience) != 1 || got.Audience[0] != "hanzo-console" {
t.Errorf("aud = %v, want [hanzo-console]", got.Audience)
}
if got.Email != "[email protected]" {
t.Errorf("email = %q, want [email protected]", got.Email)
}
if !got.EmailVerified {
t.Error("email_verified was lost")
}
if got.Phone != "+15551234567" {
t.Errorf("phone = %q", got.Phone)
}
}
// --- round trips: mint → parse against the signing cert ---
func TestRoundTripRS256(t *testing.T) {
key := rsaKey(t)
raw, err := jwt.Sign(key, sampleClaims())
if err != nil {
t.Fatalf("sign: %v", err)
}
got, err := jwt.Parse(raw, key)
if err != nil {
t.Fatalf("parse: %v", err)
}
assertClaims(t, got)
}
func TestRoundTripMLDSA65(t *testing.T) {
key := mldsaKey(t)
raw, err := jwt.Sign(key, sampleClaims())
if err != nil {
t.Fatalf("sign: %v", err)
}
got, err := jwt.Parse(raw, key)
if err != nil {
t.Fatalf("parse: %v", err)
}
assertClaims(t, got)
}
func TestRoundTripES256(t *testing.T) {
key := ecKey(t)
raw, err := jwt.Sign(key, sampleClaims())
if err != nil {
t.Fatalf("sign: %v", err)
}
got, err := jwt.Parse(raw, key)
if err != nil {
t.Fatalf("parse: %v", err)
}
assertClaims(t, got)
}
// --- round trips: mint → publish JWKS → verify against the JWKS ---
func TestJWKSRoundTripRS256(t *testing.T) {
key := rsaKey(t)
raw, err := jwt.Sign(key, sampleClaims())
if err != nil {
t.Fatalf("sign: %v", err)
}
jwks, err := jwt.BuildJWKS(key)
if err != nil {
t.Fatalf("build jwks: %v", err)
}
got, err := jwt.ParseJWKS(raw, jwks)
if err != nil {
t.Fatalf("parse jwks: %v", err)
}
assertClaims(t, got)
}
func TestJWKSRoundTripMLDSA65(t *testing.T) {
key := mldsaKey(t)
raw, err := jwt.Sign(key, sampleClaims())
if err != nil {
t.Fatalf("sign: %v", err)
}
jwks, err := jwt.BuildJWKS(key)
if err != nil {
t.Fatalf("build jwks: %v", err)
}
got, err := jwt.ParseJWKS(raw, jwks)
if err != nil {
t.Fatalf("parse jwks: %v", err)
}
assertClaims(t, got)
}
// A multi-key JWKS (RSA + ML-DSA) selects the right key by kid.
func TestJWKSMultiKeySelectsByKid(t *testing.T) {
rsa := rsaKey(t)
pq := mldsaKey(t)
jwks, err := jwt.BuildJWKS(rsa, pq)
if err != nil {
t.Fatalf("build jwks: %v", err)
}
for _, key := range []jwt.Key{rsa, pq} {
raw, err := jwt.Sign(key, sampleClaims())
if err != nil {
t.Fatalf("sign %s: %v", key.CryptoAlgorithm, err)
}
got, err := jwt.ParseJWKS(raw, jwks)
if err != nil {
t.Fatalf("parse jwks %s: %v", key.CryptoAlgorithm, err)
}
assertClaims(t, got)
}
}
// --- enforcement + negative paths ---
func TestParseEnforcesIssuerAndAudience(t *testing.T) {
key := rsaKey(t)
raw, err := jwt.Sign(key, sampleClaims())
if err != nil {
t.Fatalf("sign: %v", err)
}
if _, err := jwt.Parse(raw, key, jwt.WithIssuer("https://hanzo.id"), jwt.WithAudience("hanzo-console")); err != nil {
t.Fatalf("expected valid with matching issuer+audience: %v", err)
}
if _, err := jwt.Parse(raw, key, jwt.WithAudience("evil-app")); err == nil {
t.Fatal("expected audience mismatch to be rejected")
}
if _, err := jwt.Parse(raw, key, jwt.WithIssuer("https://evil.example")); err == nil {
t.Fatal("expected issuer mismatch to be rejected")
}
}
func TestParseRejectsWrongKey(t *testing.T) {
raw, err := jwt.Sign(rsaKey(t), sampleClaims())
if err != nil {
t.Fatalf("sign: %v", err)
}
if _, err := jwt.Parse(raw, rsaKey(t)); err == nil {
t.Fatal("expected verification against a different key to fail")
}
}
func TestParseRejectsExpired(t *testing.T) {
key := rsaKey(t)
c := sampleClaims()
c.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Hour))
raw, err := jwt.Sign(key, c)
if err != nil {
t.Fatalf("sign: %v", err)
}
if _, err := jwt.Parse(raw, key); err == nil {
t.Fatal("expected expired token to be rejected")
}
}
// --- key material helpers (self-signed test certs) ---
func rsaKey(t *testing.T) jwt.Key {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
return jwt.Key{
Name: "rsa-test",
Certificate: certPEM(t, priv, &priv.PublicKey),
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})),
CryptoAlgorithm: "RS256",
BitSize: 2048,
}
}
func ecKey(t *testing.T) jwt.Key {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
der, err := x509.MarshalECPrivateKey(priv)
if err != nil {
t.Fatal(err)
}
return jwt.Key{
Name: "ec-test",
Certificate: certPEM(t, priv, &priv.PublicKey),
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})),
CryptoAlgorithm: "ES256",
}
}
func mldsaKey(t *testing.T) jwt.Key {
t.Helper()
cert, priv, err := jwt.GenerateMLDSA65Keys()
if err != nil {
t.Fatal(err)
}
return jwt.Key{
Name: "mldsa-test",
Certificate: cert,
PrivateKey: priv,
CryptoAlgorithm: "MLDSA65",
}
}
func certPEM(t *testing.T, signer crypto.Signer, pub interface{}) string {
t.Helper()
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test", Organization: []string{"hanzo"}},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, signer)
if err != nil {
t.Fatal(err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
}