151 lines
5.1 KiB
Go
151 lines
5.1 KiB
Go
package security_test
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
|
|
"backend/internal/platform/security"
|
|
)
|
|
|
|
const testSecret = "test-secret-please-do-not-use-in-prod"
|
|
|
|
func TestIssueAndParseAccessToken_RoundTrip(t *testing.T) {
|
|
userID := uuid.New()
|
|
|
|
token, err := security.IssueAccessToken(testSecret, time.Minute, userID, "admin", security.AudienceAdmin)
|
|
if err != nil {
|
|
t.Fatalf("IssueAccessToken() error = %v", err)
|
|
}
|
|
|
|
claims, err := security.ParseAccessToken(testSecret, token, security.AudienceAdmin)
|
|
if err != nil {
|
|
t.Fatalf("ParseAccessToken() error = %v", err)
|
|
}
|
|
|
|
if claims.Subject != userID.String() {
|
|
t.Errorf("claims.Subject = %q, want %q", claims.Subject, userID.String())
|
|
}
|
|
if claims.Role != "admin" {
|
|
t.Errorf("claims.Role = %q, want %q", claims.Role, "admin")
|
|
}
|
|
if claims.ID == "" {
|
|
t.Error("claims.ID (jti) is empty, want a non-empty token id")
|
|
}
|
|
}
|
|
|
|
func TestIssueAccessToken_RejectsEmptyRole(t *testing.T) {
|
|
_, err := security.IssueAccessToken(testSecret, time.Minute, uuid.New(), "", security.AudienceAdmin)
|
|
if err == nil {
|
|
t.Fatal("IssueAccessToken() error = nil, want error for empty role")
|
|
}
|
|
}
|
|
|
|
func TestParseAccessToken_WrongAudienceRejected(t *testing.T) {
|
|
userID := uuid.New()
|
|
token, err := security.IssueAccessToken(testSecret, time.Minute, userID, "admin", security.AudienceAdmin)
|
|
if err != nil {
|
|
t.Fatalf("IssueAccessToken() error = %v", err)
|
|
}
|
|
|
|
// An admin-space token must never be accepted as a customer-space token,
|
|
// even though both spaces share the same signing secret.
|
|
if _, err := security.ParseAccessToken(testSecret, token, security.AudienceCustomer); err == nil {
|
|
t.Fatal("ParseAccessToken() error = nil, want error when audience does not match expected space")
|
|
}
|
|
}
|
|
|
|
func TestParseAccessToken_WrongSecretRejected(t *testing.T) {
|
|
token, err := security.IssueAccessToken(testSecret, time.Minute, uuid.New(), "admin", security.AudienceAdmin)
|
|
if err != nil {
|
|
t.Fatalf("IssueAccessToken() error = %v", err)
|
|
}
|
|
|
|
if _, err := security.ParseAccessToken("a-different-secret", token, security.AudienceAdmin); err == nil {
|
|
t.Fatal("ParseAccessToken() error = nil, want error for a token signed with a different secret")
|
|
}
|
|
}
|
|
|
|
func TestParseAccessToken_ExpiredTokenRejected(t *testing.T) {
|
|
token, err := security.IssueAccessToken(testSecret, -time.Minute, uuid.New(), "admin", security.AudienceAdmin)
|
|
if err != nil {
|
|
t.Fatalf("IssueAccessToken() error = %v", err)
|
|
}
|
|
|
|
if _, err := security.ParseAccessToken(testSecret, token, security.AudienceAdmin); err == nil {
|
|
t.Fatal("ParseAccessToken() error = nil, want error for an already-expired token")
|
|
}
|
|
}
|
|
|
|
func TestParseAccessToken_RejectsNoneAlgorithm(t *testing.T) {
|
|
// Craft a token that declares "alg":"none" and carries otherwise valid
|
|
// claims, to make sure the parser refuses it outright rather than
|
|
// trusting an attacker-chosen algorithm.
|
|
claims := security.Claims{
|
|
Role: "admin",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: uuid.New().String(),
|
|
Audience: jwt.ClaimStrings{string(security.AudienceAdmin)},
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
ID: uuid.NewString(),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
|
|
unsigned, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
|
if err != nil {
|
|
t.Fatalf("craft none-alg token: %v", err)
|
|
}
|
|
|
|
if _, err := security.ParseAccessToken(testSecret, unsigned, security.AudienceAdmin); err == nil {
|
|
t.Fatal("ParseAccessToken() error = nil, want error for an alg=none token")
|
|
}
|
|
}
|
|
|
|
func TestParseAccessToken_RejectsInvalidSubject(t *testing.T) {
|
|
secretBytes := []byte(testSecret)
|
|
claims := security.Claims{
|
|
Role: "admin",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: "not-a-uuid",
|
|
Audience: jwt.ClaimStrings{string(security.AudienceAdmin)},
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
ID: uuid.NewString(),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
signed, err := token.SignedString(secretBytes)
|
|
if err != nil {
|
|
t.Fatalf("sign token: %v", err)
|
|
}
|
|
|
|
if _, err := security.ParseAccessToken(testSecret, signed, security.AudienceAdmin); err == nil {
|
|
t.Fatal("ParseAccessToken() error = nil, want error for a non-UUID sub claim")
|
|
}
|
|
}
|
|
|
|
func TestParseAccessToken_RejectsMissingRole(t *testing.T) {
|
|
secretBytes := []byte(testSecret)
|
|
claims := security.Claims{
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: uuid.New().String(),
|
|
Audience: jwt.ClaimStrings{string(security.AudienceAdmin)},
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
ID: uuid.NewString(),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
signed, err := token.SignedString(secretBytes)
|
|
if err != nil {
|
|
t.Fatalf("sign token: %v", err)
|
|
}
|
|
|
|
if _, err := security.ParseAccessToken(testSecret, signed, security.AudienceAdmin); err == nil {
|
|
t.Fatal("ParseAccessToken() error = nil, want error when role claim is missing")
|
|
}
|
|
}
|