mirror of
https://github.com/hanzoai/jwt.git
synced 2026-09-30 22:47:57 +00:00
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.
306 lines
7.9 KiB
Go
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}))
|
|
}
|