gomcp/internal/infrastructure/auth/jwt.go
DmitrL-dev 62ecc1c7a3 sec: fix C4/C5/M4/M5 + domain migration to syntrex.pro
C4: Remove localhost:9100 fallback from 27 dashboard files (use relative URLs)
C5: JWT token_type differentiation (access vs refresh) - middleware rejects refresh as Bearer
M4: Server-side registration gate via SOC_REGISTRATION_OPEN env var
M5: HTML tag stripping on name/org_name fields (XSS prevention)

Domain migration:
- users.go: admin@syntrex.pro
- zerotrust.go: SPIFFE trust domain
- sbom.go: namespace URL
- .env.production.example: all URLs updated
- identity_test.go: test email
2026-03-24 11:49:33 +10:00

140 lines
3.8 KiB
Go

// Package auth provides JWT authentication for the SOC HTTP API.
// Uses HMAC-SHA256 (HS256) with configurable secret.
// Zero external dependencies — pure Go stdlib.
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// Standard JWT errors.
var (
ErrInvalidToken = errors.New("auth: invalid token")
ErrExpiredToken = errors.New("auth: token expired")
ErrInvalidSecret = errors.New("auth: secret too short (min 32 bytes)")
ErrWrongTokenType = errors.New("auth: wrong token type")
)
// Claims represents JWT payload.
type Claims struct {
Sub string `json:"sub"` // Subject (username or user ID)
Role string `json:"role"` // RBAC role: admin, operator, analyst, viewer
TenantID string `json:"tenant_id,omitempty"` // Multi-tenant isolation
TokenType string `json:"token_type,omitempty"` // "access" or "refresh"
Exp int64 `json:"exp"` // Expiration (Unix timestamp)
Iat int64 `json:"iat"` // Issued at
Iss string `json:"iss,omitempty"` // Issuer
}
// IsExpired returns true if the token has expired.
func (c Claims) IsExpired() bool {
return time.Now().Unix() > c.Exp
}
// header is the JWT header (always HS256).
var jwtHeader = base64URLEncode([]byte(`{"alg":"HS256","typ":"JWT"}`))
// Sign creates a JWT token string from claims.
func Sign(claims Claims, secret []byte) (string, error) {
if len(secret) < 32 {
return "", ErrInvalidSecret
}
if claims.Iat == 0 {
claims.Iat = time.Now().Unix()
}
if claims.Iss == "" {
claims.Iss = "sentinel-soc"
}
payload, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("auth: marshal claims: %w", err)
}
encodedPayload := base64URLEncode(payload)
signingInput := jwtHeader + "." + encodedPayload
signature := hmacSign([]byte(signingInput), secret)
return signingInput + "." + signature, nil
}
// Verify validates a JWT token string and returns the claims.
func Verify(tokenStr string, secret []byte) (*Claims, error) {
parts := strings.SplitN(tokenStr, ".", 3)
if len(parts) != 3 {
return nil, ErrInvalidToken
}
signingInput := parts[0] + "." + parts[1]
expectedSig := hmacSign([]byte(signingInput), secret)
if !hmac.Equal([]byte(parts[2]), []byte(expectedSig)) {
return nil, ErrInvalidToken
}
payload, err := base64URLDecode(parts[1])
if err != nil {
return nil, fmt.Errorf("%w: bad payload encoding", ErrInvalidToken)
}
var claims Claims
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("%w: bad payload JSON", ErrInvalidToken)
}
if claims.IsExpired() {
return nil, ErrExpiredToken
}
return &claims, nil
}
// NewAccessToken creates a short-lived access token (15 min default).
func NewAccessToken(subject, role string, secret []byte, ttl time.Duration) (string, error) {
if ttl == 0 {
ttl = 15 * time.Minute
}
return Sign(Claims{
Sub: subject,
Role: role,
TokenType: "access",
Exp: time.Now().Add(ttl).Unix(),
}, secret)
}
// NewRefreshToken creates a long-lived refresh token (7 days default).
func NewRefreshToken(subject, role string, secret []byte, ttl time.Duration) (string, error) {
if ttl == 0 {
ttl = 7 * 24 * time.Hour
}
return Sign(Claims{
Sub: subject,
Role: role,
TokenType: "refresh",
Exp: time.Now().Add(ttl).Unix(),
}, secret)
}
// --- base64url helpers (RFC 7515) ---
func base64URLEncode(data []byte) string {
return base64.RawURLEncoding.EncodeToString(data)
}
func base64URLDecode(s string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(s)
}
func hmacSign(data, secret []byte) string {
mac := hmac.New(sha256.New, secret)
mac.Write(data)
return base64URLEncode(mac.Sum(nil))
}