package auth import ( "crypto/ed25519" "crypto/rand" "crypto/x509" "encoding/base64" "encoding/pem" "errors" "strings" "testing" "time" "github.com/golang-jwt/jwt/v5" ) // generatePKCS8PEM produces a PEM-encoded PKCS#8 Ed25519 private key. // This is the format `openssl genpkey -algorithm ED25519` emits and the // format the production code documents in jwt_private_key. func generatePKCS8PEM(t *testing.T) (string, ed25519.PrivateKey) { t.Helper() _, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("ed25519.GenerateKey: %v", err) } der, err := x509.MarshalPKCS8PrivateKey(priv) if err != nil { t.Fatalf("MarshalPKCS8PrivateKey: %v", err) } pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) return string(pemBytes), priv } // generateRawPEM wraps a raw 64-byte Ed25519 key in a PEM block. The // production code accepts this as a fallback when PKCS#8 parsing fails. func generateRawPEM(t *testing.T) (string, ed25519.PrivateKey) { t.Helper() _, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("ed25519.GenerateKey: %v", err) } pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: priv}) return string(pemBytes), priv } func TestParseEd25519PrivateKeyFromPEM_PKCS8(t *testing.T) { pemStr, want := generatePKCS8PEM(t) got, err := parseEd25519PrivateKeyFromPEM(pemStr) if err != nil { t.Fatalf("unexpected error: %v", err) } if !got.Equal(want) { t.Errorf("parsed key does not equal generated key") } } func TestParseEd25519PrivateKeyFromPEM_RawBytes(t *testing.T) { pemStr, want := generateRawPEM(t) got, err := parseEd25519PrivateKeyFromPEM(pemStr) if err != nil { t.Fatalf("unexpected error: %v", err) } if !got.Equal(want) { t.Errorf("parsed raw key does not equal generated key") } } func TestParseEd25519PrivateKeyFromPEM_NotPEM(t *testing.T) { _, err := parseEd25519PrivateKeyFromPEM("this is not a pem block") if err == nil { t.Fatal("expected error for non-PEM input, got nil") } if !strings.Contains(err.Error(), "decode PEM block") { t.Errorf("unexpected error: %v", err) } } func TestParseEd25519PrivateKeyFromPEM_PKCS8WrongKeyType(t *testing.T) { // Generate a non-Ed25519 PKCS#8 key (RSA would require crypto/rsa; instead // we craft a PKCS#8 wrapping for an ECDSA key via x509). The simplest // portable way is to use a known-bad DER blob: a PKCS#8 wrapping of an // ed25519 PUBLIC key, which ParsePKCS8PrivateKey will reject as not a // private key. To keep the test deterministic and dependency-free, we // instead build a PEM of length-mismatched bytes that's neither PKCS#8 // nor 64 raw bytes. pemBytes := pem.EncodeToMemory(&pem.Block{ Type: "PRIVATE KEY", Bytes: []byte("definitely not a valid pkcs8 or raw ed25519 key"), }) _, err := parseEd25519PrivateKeyFromPEM(string(pemBytes)) if err == nil { t.Fatal("expected error for invalid key bytes, got nil") } if !strings.Contains(err.Error(), "invalid Ed25519 private key format") { t.Errorf("unexpected error: %v", err) } } func TestNewJWTService_AutoGeneratesKeyPair(t *testing.T) { svc, err := NewJWTService() if err != nil { t.Fatalf("NewJWTService: %v", err) } if svc.privateKey == nil { t.Error("privateKey is nil after auto-generate") } if svc.publicKey == nil { t.Error("publicKey is nil after auto-generate") } if len(svc.privateKey) != ed25519.PrivateKeySize { t.Errorf("privateKey size = %d, want %d", len(svc.privateKey), ed25519.PrivateKeySize) } if len(svc.publicKey) != ed25519.PublicKeySize { t.Errorf("publicKey size = %d, want %d", len(svc.publicKey), ed25519.PublicKeySize) } if svc.stateStore == nil || svc.stateStore.states == nil { t.Error("stateStore not initialized") } } func TestNewJWTServiceWithKey_EmptyStringAutoGenerates(t *testing.T) { svc, err := NewJWTServiceWithKey("") if err != nil { t.Fatalf("NewJWTServiceWithKey(\"\"): %v", err) } if svc.privateKey == nil || svc.publicKey == nil { t.Error("expected auto-generated keys for empty PEM input") } } func TestNewJWTServiceWithKey_PKCS8(t *testing.T) { pemStr, want := generatePKCS8PEM(t) svc, err := NewJWTServiceWithKey(pemStr) if err != nil { t.Fatalf("NewJWTServiceWithKey: %v", err) } if !svc.privateKey.Equal(want) { t.Error("loaded privateKey does not match input") } // Public key must match the public part of the loaded private key. wantPub := want.Public().(ed25519.PublicKey) if !svc.publicKey.Equal(wantPub) { t.Error("derived publicKey does not match") } } func TestNewJWTServiceWithKey_BadPEMReturnsWrappedError(t *testing.T) { _, err := NewJWTServiceWithKey("garbage") if err == nil { t.Fatal("expected error for bad PEM, got nil") } if !strings.Contains(err.Error(), "failed to parse Ed25519 private key") { t.Errorf("expected wrapping error, got %v", err) } } func newTestUserInfo() *UserInfo { return &UserInfo{ Username: "alice", Email: "alice@example.com", Name: "Alice Example", Roles: []string{"admin", "viewer"}, } } func TestGenerateAndValidateToken_RoundTrip(t *testing.T) { svc, err := NewJWTService() if err != nil { t.Fatalf("NewJWTService: %v", err) } user := newTestUserInfo() tok, err := svc.GenerateToken(user, 60) if err != nil { t.Fatalf("GenerateToken: %v", err) } if tok == "" { t.Fatal("GenerateToken returned empty string") } claims, err := svc.ValidateToken(tok) if err != nil { t.Fatalf("ValidateToken: %v", err) } if claims.Username != user.Username { t.Errorf("Username = %q, want %q", claims.Username, user.Username) } if claims.Email != user.Email { t.Errorf("Email = %q, want %q", claims.Email, user.Email) } if claims.Name != user.Name { t.Errorf("Name = %q, want %q", claims.Name, user.Name) } if len(claims.Roles) != 2 || claims.Roles[0] != "admin" || claims.Roles[1] != "viewer" { t.Errorf("Roles = %v, want [admin viewer]", claims.Roles) } // ExpiresAt should be ~60s in the future. if claims.ExpiresAt == nil { t.Fatal("ExpiresAt nil") } if d := time.Until(claims.ExpiresAt.Time); d <= 0 || d > 61*time.Second { t.Errorf("ExpiresAt delta = %v, want (0,61s]", d) } } func TestValidateToken_Expired(t *testing.T) { svc, err := NewJWTService() if err != nil { t.Fatalf("NewJWTService: %v", err) } // sessionMaxAge = -1s → token is born expired. tok, err := svc.GenerateToken(newTestUserInfo(), -1) if err != nil { t.Fatalf("GenerateToken: %v", err) } _, err = svc.ValidateToken(tok) if err == nil { t.Fatal("expected expired-token error, got nil") } if !strings.Contains(err.Error(), "failed to parse token") { t.Errorf("unexpected error: %v", err) } // jwt/v5 surfaces ErrTokenExpired wrapped in the parse error. if !errors.Is(err, jwt.ErrTokenExpired) { t.Errorf("expected wrapped jwt.ErrTokenExpired, got %v", err) } } func TestValidateToken_SignedByDifferentKey(t *testing.T) { signer, err := NewJWTService() if err != nil { t.Fatalf("signer: %v", err) } verifier, err := NewJWTService() if err != nil { t.Fatalf("verifier: %v", err) } tok, err := signer.GenerateToken(newTestUserInfo(), 60) if err != nil { t.Fatalf("GenerateToken: %v", err) } if _, err := verifier.ValidateToken(tok); err == nil { t.Fatal("expected signature-mismatch error, got nil") } } func TestValidateToken_Malformed(t *testing.T) { svc, err := NewJWTService() if err != nil { t.Fatalf("NewJWTService: %v", err) } cases := []string{ "", "not.a.jwt", "only-one-segment", "two.segments", "aaaa.bbbb.cccc", // valid shape, invalid base64/JSON } for _, c := range cases { t.Run(c, func(t *testing.T) { if _, err := svc.ValidateToken(c); err == nil { t.Errorf("expected error for %q, got nil", c) } }) } } func TestValidateToken_WrongSigningMethod(t *testing.T) { svc, err := NewJWTService() if err != nil { t.Fatalf("NewJWTService: %v", err) } // Forge an HS256 token with the same claim shape; ValidateToken's // keyfunc must reject the alg before signature verification. claims := SessionClaims{ Username: "mallory", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), }, } tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := tok.SignedString([]byte("a-shared-secret")) if err != nil { t.Fatalf("sign HS256: %v", err) } _, err = svc.ValidateToken(signed) if err == nil { t.Fatal("expected error for non-EdDSA token, got nil") } if !strings.Contains(err.Error(), "unexpected signing method") { t.Errorf("expected signing-method error, got %v", err) } } func TestGenerateToken_NilPrivateKeyReturnsError(t *testing.T) { // Construct a service with a nil key directly. This guards the explicit // nil-check at the top of GenerateToken. svc := &JWTService{} _, err := svc.GenerateToken(newTestUserInfo(), 60) if err == nil { t.Fatal("expected error for nil private key, got nil") } if !strings.Contains(err.Error(), "private key not initialized") { t.Errorf("unexpected error: %v", err) } } func TestValidateToken_NilPublicKeyReturnsError(t *testing.T) { svc := &JWTService{} _, err := svc.ValidateToken("anything") if err == nil { t.Fatal("expected error for nil public key, got nil") } if !strings.Contains(err.Error(), "public key not initialized") { t.Errorf("unexpected error: %v", err) } } func TestGenerateStateToken_ProducesUniqueValues(t *testing.T) { svc, err := NewJWTService() if err != nil { t.Fatalf("NewJWTService: %v", err) } a, err := svc.GenerateStateToken() if err != nil { t.Fatalf("GenerateStateToken: %v", err) } b, err := svc.GenerateStateToken() if err != nil { t.Fatalf("GenerateStateToken: %v", err) } if a == "" || b == "" { t.Fatal("state token is empty") } if a == b { t.Errorf("state tokens collided: %q", a) } } func TestValidateAndConsumeState_HappyPath(t *testing.T) { svc, err := NewJWTService() if err != nil { t.Fatalf("NewJWTService: %v", err) } tok, err := svc.GenerateStateToken() if err != nil { t.Fatalf("GenerateStateToken: %v", err) } if !svc.ValidateAndConsumeState(tok) { t.Error("first consume should succeed") } } func TestValidateAndConsumeState_IsSingleUse(t *testing.T) { svc, err := NewJWTService() if err != nil { t.Fatalf("NewJWTService: %v", err) } tok, err := svc.GenerateStateToken() if err != nil { t.Fatalf("GenerateStateToken: %v", err) } _ = svc.ValidateAndConsumeState(tok) if svc.ValidateAndConsumeState(tok) { t.Error("second consume should fail") } } func TestValidateAndConsumeState_UnknownTokenRejected(t *testing.T) { svc, err := NewJWTService() if err != nil { t.Fatalf("NewJWTService: %v", err) } if svc.ValidateAndConsumeState("never-issued") { t.Error("unknown token must not validate") } } func TestValidateAndConsumeState_ExpiredTokenRejected(t *testing.T) { svc, err := NewJWTService() if err != nil { t.Fatalf("NewJWTService: %v", err) } // Inject an expired entry directly to avoid a real 10-minute wait. svc.stateStore.states["expired"] = StateData{ Created: time.Now().Add(-20 * time.Minute), ExpiresAt: time.Now().Add(-10 * time.Minute), } if svc.ValidateAndConsumeState("expired") { t.Error("expired token must not validate") } // And it should be deleted as a side effect of the rejection. if _, exists := svc.stateStore.states["expired"]; exists { t.Error("expired token should be removed from the store") } } func TestGetPublicKeyPEM_ParsesBackToOriginalKey(t *testing.T) { svc, err := NewJWTService() if err != nil { t.Fatalf("NewJWTService: %v", err) } pemStr, err := svc.GetPublicKeyPEM() if err != nil { t.Fatalf("GetPublicKeyPEM: %v", err) } block, _ := pem.Decode([]byte(pemStr)) if block == nil { t.Fatalf("returned PEM did not decode: %q", pemStr) } if block.Type != "PUBLIC KEY" { t.Errorf("PEM type = %q, want PUBLIC KEY", block.Type) } // The implementation writes the raw 32-byte public key as the block body. if len(block.Bytes) != ed25519.PublicKeySize { t.Errorf("body length = %d, want %d", len(block.Bytes), ed25519.PublicKeySize) } if !ed25519.PublicKey(block.Bytes).Equal(svc.publicKey) { t.Error("decoded public key does not match service key") } } func TestGetPublicKeyBase64_RoundTripsToOriginalKey(t *testing.T) { svc, err := NewJWTService() if err != nil { t.Fatalf("NewJWTService: %v", err) } b64, err := svc.GetPublicKeyBase64() if err != nil { t.Fatalf("GetPublicKeyBase64: %v", err) } if b64 == "" { t.Fatal("empty base64 output") } // base64.RawURLEncoding (no padding) is what the production code uses. // Decode and compare. // Use the std encoding through helper to keep the import list small. got, err := decodeRawURL(b64) if err != nil { t.Fatalf("base64 decode: %v", err) } if !ed25519.PublicKey(got).Equal(svc.publicKey) { t.Error("base64-decoded key does not match service key") } } func TestGetPublicKeyPEM_NilKeyReturnsError(t *testing.T) { svc := &JWTService{} if _, err := svc.GetPublicKeyPEM(); err == nil { t.Error("expected error for nil public key") } } func TestGetPublicKeyBase64_NilKeyReturnsError(t *testing.T) { svc := &JWTService{} if _, err := svc.GetPublicKeyBase64(); err == nil { t.Error("expected error for nil public key") } } // decodeRawURL is a tiny shim around encoding/base64's RawURLEncoding decoder // so the test body stays focused on assertions, not encoding plumbing. func decodeRawURL(s string) ([]byte, error) { return base64RawURLDecode(s) } func base64RawURLDecode(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }