180 lines
4.7 KiB
Go
180 lines
4.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
var (
|
|
// ErrInvalidToken is returned when the token is malformed or signature is invalid.
|
|
ErrInvalidToken = errors.New("invalid token")
|
|
// ErrExpiredToken is returned when the token has expired.
|
|
ErrExpiredToken = errors.New("token expired")
|
|
// ErrInvalidClaims is returned when the token claims are invalid.
|
|
ErrInvalidClaims = errors.New("invalid claims")
|
|
)
|
|
|
|
// JWTConfig configures the JWT validator.
|
|
type JWTConfig struct {
|
|
// Secret is the HMAC secret key for HS256/HS384/HS512
|
|
Secret []byte
|
|
// PublicKey is the RSA/ECDSA public key for RS*/ES* algorithms (optional)
|
|
PublicKey any
|
|
// Issuer is the expected issuer claim (optional)
|
|
Issuer string
|
|
// Audience is the expected audience claim (optional)
|
|
Audience string
|
|
}
|
|
|
|
// JWTClaims extends jwt.RegisteredClaims with custom fields.
|
|
type JWTClaims struct {
|
|
jwt.RegisteredClaims
|
|
// UserID is the user identifier
|
|
UserID string `json:"uid,omitempty"`
|
|
// Email is the user's email
|
|
Email string `json:"email,omitempty"`
|
|
// Roles are the user's roles
|
|
Roles []string `json:"roles,omitempty"`
|
|
// Scopes are the permitted scopes
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
}
|
|
|
|
// JWTValidator validates JWT tokens.
|
|
type JWTValidator struct {
|
|
secret []byte
|
|
publicKey any
|
|
issuer string
|
|
audience string
|
|
}
|
|
|
|
// NewJWTValidator creates a new JWT validator.
|
|
func NewJWTValidator(cfg JWTConfig) *JWTValidator {
|
|
return &JWTValidator{
|
|
secret: cfg.Secret,
|
|
publicKey: cfg.PublicKey,
|
|
issuer: cfg.Issuer,
|
|
audience: cfg.Audience,
|
|
}
|
|
}
|
|
|
|
// Validate validates a JWT token and returns the user.
|
|
func (v *JWTValidator) Validate(ctx context.Context, tokenString string) (*User, error) {
|
|
// Parse and validate the token
|
|
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (any, error) {
|
|
// Check signing method
|
|
switch token.Method.(type) {
|
|
case *jwt.SigningMethodHMAC:
|
|
if v.secret == nil {
|
|
return nil, fmt.Errorf("HMAC secret not configured")
|
|
}
|
|
return v.secret, nil
|
|
case *jwt.SigningMethodRSA, *jwt.SigningMethodECDSA:
|
|
if v.publicKey == nil {
|
|
return nil, fmt.Errorf("public key not configured")
|
|
}
|
|
return v.publicKey, nil
|
|
default:
|
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
}
|
|
})
|
|
|
|
if err != nil {
|
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
|
return nil, ErrExpiredToken
|
|
}
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
|
}
|
|
|
|
claims, ok := token.Claims.(*JWTClaims)
|
|
if !ok || !token.Valid {
|
|
return nil, ErrInvalidClaims
|
|
}
|
|
|
|
// Validate issuer if configured
|
|
if v.issuer != "" && claims.Issuer != v.issuer {
|
|
return nil, fmt.Errorf("%w: invalid issuer", ErrInvalidClaims)
|
|
}
|
|
|
|
// Validate audience if configured
|
|
if v.audience != "" {
|
|
found := false
|
|
for _, aud := range claims.Audience {
|
|
if aud == v.audience {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("%w: invalid audience", ErrInvalidClaims)
|
|
}
|
|
}
|
|
|
|
// Build user from claims
|
|
user := &User{
|
|
ID: claims.UserID,
|
|
Email: claims.Email,
|
|
Roles: claims.Roles,
|
|
Scopes: claims.Scopes,
|
|
}
|
|
|
|
// Fallback to subject if no user ID
|
|
if user.ID == "" {
|
|
user.ID = claims.Subject
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Token Generation (for testing and admin tools)
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// GenerateToken creates a new JWT token for the given user.
|
|
// expiresIn specifies the token lifetime.
|
|
func GenerateToken(secret []byte, user *User, expiresIn time.Duration) (string, error) {
|
|
now := time.Now()
|
|
|
|
claims := JWTClaims{
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: user.ID,
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(expiresIn)),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
NotBefore: jwt.NewNumericDate(now),
|
|
},
|
|
UserID: user.ID,
|
|
Email: user.Email,
|
|
Roles: user.Roles,
|
|
Scopes: user.Scopes,
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
return token.SignedString(secret)
|
|
}
|
|
|
|
// GenerateTokenWithIssuer creates a new JWT token with issuer and audience claims.
|
|
func GenerateTokenWithIssuer(secret []byte, user *User, expiresIn time.Duration, issuer, audience string) (string, error) {
|
|
now := time.Now()
|
|
|
|
claims := JWTClaims{
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: user.ID,
|
|
Issuer: issuer,
|
|
Audience: []string{audience},
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(expiresIn)),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
NotBefore: jwt.NewNumericDate(now),
|
|
},
|
|
UserID: user.ID,
|
|
Email: user.Email,
|
|
Roles: user.Roles,
|
|
Scopes: user.Scopes,
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
return token.SignedString(secret)
|
|
}
|