- Added `web/templates/{dashboard,audit,base,accounts,account_detail}.html` for a consistent UI.
- Implemented new audit log endpoint (`GET /v1/audit`) with filtering and pagination via `ListAuditEventsPaged`.
- Extended `AuditQueryParams`, added `AuditEventView` for joined actor/target usernames.
- Updated configuration (`goimports` preference), linting rules, and E2E tests.
- No logic changes to existing APIs.
228 lines
6.5 KiB
Go
228 lines
6.5 KiB
Go
package token
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
|
t.Helper()
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate key: %v", err)
|
|
}
|
|
return pub, priv
|
|
}
|
|
|
|
// b64url encodes a string as base64url without padding.
|
|
func b64url(s string) string {
|
|
return base64.RawURLEncoding.EncodeToString([]byte(s))
|
|
}
|
|
|
|
const testIssuer = "https://auth.example.com"
|
|
|
|
func TestIssueAndValidateToken(t *testing.T) {
|
|
pub, priv := generateTestKey(t)
|
|
roles := []string{"admin", "reader"}
|
|
|
|
tokenStr, claims, err := IssueToken(priv, testIssuer, "user-uuid-1", roles, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("IssueToken: %v", err)
|
|
}
|
|
if tokenStr == "" {
|
|
t.Fatal("IssueToken returned empty token string")
|
|
}
|
|
if claims.JTI == "" {
|
|
t.Error("JTI must not be empty")
|
|
}
|
|
if claims.Subject != "user-uuid-1" {
|
|
t.Errorf("Subject = %q, want %q", claims.Subject, "user-uuid-1")
|
|
}
|
|
|
|
// Validate the token.
|
|
got, err := ValidateToken(pub, tokenStr, testIssuer)
|
|
if err != nil {
|
|
t.Fatalf("ValidateToken: %v", err)
|
|
}
|
|
if got.Subject != "user-uuid-1" {
|
|
t.Errorf("validated Subject = %q, want %q", got.Subject, "user-uuid-1")
|
|
}
|
|
if got.JTI != claims.JTI {
|
|
t.Errorf("validated JTI = %q, want %q", got.JTI, claims.JTI)
|
|
}
|
|
if len(got.Roles) != 2 {
|
|
t.Errorf("validated Roles = %v, want 2 roles", got.Roles)
|
|
}
|
|
}
|
|
|
|
// TestValidateTokenWrongAlgorithm verifies that tokens with non-EdDSA alg are
|
|
// rejected immediately, before any signature verification.
|
|
// Security: This tests the core defence against algorithm-confusion attacks.
|
|
func TestValidateTokenWrongAlgorithm(t *testing.T) {
|
|
_, priv := generateTestKey(t)
|
|
pub, _ := generateTestKey(t) // different key — but alg check should fail first
|
|
|
|
// Forge a token signed with HMAC-SHA256 (alg: HS256).
|
|
hmacToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"iss": testIssuer,
|
|
"sub": "attacker",
|
|
"iat": time.Now().Unix(),
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"jti": "fake-jti",
|
|
})
|
|
// Use the Ed25519 public key bytes as the HMAC secret (classic alg confusion).
|
|
pubForHMAC, ok := priv.Public().(ed25519.PublicKey)
|
|
if !ok {
|
|
t.Fatal("priv.Public() did not return ed25519.PublicKey")
|
|
}
|
|
hs256Signed, err := hmacToken.SignedString([]byte(pubForHMAC))
|
|
if err != nil {
|
|
t.Fatalf("sign HS256 token: %v", err)
|
|
}
|
|
|
|
_, err = ValidateToken(pub, hs256Signed, testIssuer)
|
|
if err == nil {
|
|
t.Fatal("expected error for HS256 token, got nil")
|
|
}
|
|
if !errors.Is(err, ErrWrongAlgorithm) {
|
|
t.Errorf("expected ErrWrongAlgorithm, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateTokenAlgNone verifies that "none" algorithm is rejected.
|
|
// Security: "none" algorithm tokens have no signature and must always be
|
|
// rejected regardless of payload content.
|
|
func TestValidateTokenAlgNone(t *testing.T) {
|
|
pub, _ := generateTestKey(t)
|
|
|
|
// Construct a "none" algorithm token manually.
|
|
// golang-jwt/v5 disallows signing with "none" directly, so we craft it
|
|
// using raw base64url encoding.
|
|
header := `{"alg":"none","typ":"JWT"}`
|
|
payload := `{"iss":"https://auth.example.com","sub":"evil","iat":1000000,"exp":9999999999,"jti":"evil-jti"}`
|
|
noneToken := b64url(header) + "." + b64url(payload) + "."
|
|
|
|
_, err := ValidateToken(pub, noneToken, testIssuer)
|
|
if err == nil {
|
|
t.Fatal("expected error for 'none' algorithm token, got nil")
|
|
}
|
|
}
|
|
|
|
// TestValidateTokenExpired verifies that expired tokens are rejected.
|
|
func TestValidateTokenExpired(t *testing.T) {
|
|
pub, priv := generateTestKey(t)
|
|
|
|
// Issue a token with a negative expiry (already expired).
|
|
tokenStr, _, err := IssueToken(priv, testIssuer, "user", nil, -time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("IssueToken: %v", err)
|
|
}
|
|
|
|
_, err = ValidateToken(pub, tokenStr, testIssuer)
|
|
if err == nil {
|
|
t.Fatal("expected error for expired token, got nil")
|
|
}
|
|
if !errors.Is(err, ErrExpiredToken) {
|
|
t.Errorf("expected ErrExpiredToken, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateTokenTamperedSignature verifies that signature tampering is caught.
|
|
func TestValidateTokenTamperedSignature(t *testing.T) {
|
|
pub, priv := generateTestKey(t)
|
|
|
|
tokenStr, _, err := IssueToken(priv, testIssuer, "user", nil, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("IssueToken: %v", err)
|
|
}
|
|
|
|
// Tamper: flip a byte in the signature (last segment).
|
|
parts := strings.Split(tokenStr, ".")
|
|
if len(parts) != 3 {
|
|
t.Fatalf("unexpected token format: %d parts", len(parts))
|
|
}
|
|
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
|
|
if err != nil {
|
|
t.Fatalf("decode signature: %v", err)
|
|
}
|
|
sigBytes[0] ^= 0x01 // flip one bit
|
|
parts[2] = base64.RawURLEncoding.EncodeToString(sigBytes)
|
|
tampered := strings.Join(parts, ".")
|
|
|
|
_, err = ValidateToken(pub, tampered, testIssuer)
|
|
if err == nil {
|
|
t.Fatal("expected error for tampered signature, got nil")
|
|
}
|
|
}
|
|
|
|
// TestValidateTokenWrongKey verifies that a token signed with a different key
|
|
// is rejected.
|
|
func TestValidateTokenWrongKey(t *testing.T) {
|
|
_, priv := generateTestKey(t)
|
|
wrongPub, _ := generateTestKey(t)
|
|
|
|
tokenStr, _, err := IssueToken(priv, testIssuer, "user", nil, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("IssueToken: %v", err)
|
|
}
|
|
|
|
_, err = ValidateToken(wrongPub, tokenStr, testIssuer)
|
|
if err == nil {
|
|
t.Fatal("expected error for wrong key, got nil")
|
|
}
|
|
}
|
|
|
|
// TestValidateTokenWrongIssuer verifies that tokens from a different issuer
|
|
// are rejected.
|
|
func TestValidateTokenWrongIssuer(t *testing.T) {
|
|
pub, priv := generateTestKey(t)
|
|
|
|
tokenStr, _, err := IssueToken(priv, "https://evil.example.com", "user", nil, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("IssueToken: %v", err)
|
|
}
|
|
|
|
_, err = ValidateToken(pub, tokenStr, testIssuer)
|
|
if err == nil {
|
|
t.Fatal("expected error for wrong issuer, got nil")
|
|
}
|
|
}
|
|
|
|
// TestJTIsAreUnique verifies that two issued tokens have different JTIs.
|
|
func TestJTIsAreUnique(t *testing.T) {
|
|
_, priv := generateTestKey(t)
|
|
|
|
_, c1, err := IssueToken(priv, testIssuer, "user", nil, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("IssueToken (1): %v", err)
|
|
}
|
|
_, c2, err := IssueToken(priv, testIssuer, "user", nil, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("IssueToken (2): %v", err)
|
|
}
|
|
if c1.JTI == c2.JTI {
|
|
t.Error("two issued tokens have the same JTI")
|
|
}
|
|
}
|
|
|
|
// TestClaimsHasRole verifies role checking.
|
|
func TestClaimsHasRole(t *testing.T) {
|
|
c := &Claims{Roles: []string{"admin", "reader"}}
|
|
if !c.HasRole("admin") {
|
|
t.Error("expected HasRole(admin) = true")
|
|
}
|
|
if !c.HasRole("reader") {
|
|
t.Error("expected HasRole(reader) = true")
|
|
}
|
|
if c.HasRole("writer") {
|
|
t.Error("expected HasRole(writer) = false")
|
|
}
|
|
}
|