Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions cmd/auth-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"gorm.io/gorm"

"shieldgate/config"
"shieldgate/internal/crypto"
"shieldgate/internal/database"
"shieldgate/internal/handlers"
"shieldgate/internal/middleware"
Expand Down Expand Up @@ -69,6 +70,19 @@ func main() {
}
}

// Initialize RSA key manager for RS256 token signing
var keyManager *crypto.KeyManager
if cfg.JWTAlgorithm != "HS256" {
km, err := crypto.NewKeyManager(cfg.RSAKeyPath)
if err != nil {
logger.Fatalf("Failed to initialize RSA key manager: %v", err)
}
keyManager = km
logger.WithField("kid", km.KID()).Info("RSA key manager initialized (RS256)")
} else {
logger.Info("JWT algorithm set to HS256; RSA key manager disabled")
}

// Initialize repositories
repos := gormrepo.NewRepositories(db)

Expand All @@ -87,7 +101,7 @@ func main() {
tenantService := services.NewTenantService(repos, logger)
userService := services.NewUserService(repos, logger)
clientService := services.NewClientService(repos, logger)
authService := services.NewAuthService(repos, cfg, logger)
authService := services.NewAuthService(repos, cfg, keyManager, logger)

// Start background email queue processor
go func() {
Expand Down Expand Up @@ -126,10 +140,10 @@ func main() {
tenantHandler := handlers.NewTenantHandler(tenantService, logger)
userHandler := handlers.NewUserHandler(userService, logger)
clientHandler := handlers.NewClientHandler(clientService, logger)
oauthHandler := handlers.NewOAuthHandler(tenantService, userService, clientService, authService, logger)
oauthHandler := handlers.NewOAuthHandler(tenantService, userService, clientService, authService, keyManager, logger)

// Setup routes
setupRoutes(cfg, db, redisClient, router, tenantHandler, userHandler, clientHandler, oauthHandler)
setupRoutes(cfg, db, redisClient, router, keyManager, tenantHandler, userHandler, clientHandler, oauthHandler)

// Create HTTP server
server := &http.Server{
Expand Down Expand Up @@ -198,6 +212,7 @@ func setupRoutes(
db *gorm.DB,
redisClient *database.RedisClient,
router *gin.Engine,
keyManager *crypto.KeyManager,
tenantHandler *handlers.TenantHandler,
userHandler *handlers.UserHandler,
clientHandler *handlers.ClientHandler,
Expand Down Expand Up @@ -249,7 +264,7 @@ func setupRoutes(

// Management API endpoints (versioned)
api := router.Group("/v1")
api.Use(middleware.RequireAuth(cfg)) // Require authentication for management APIs
api.Use(middleware.RequireAuth(cfg, keyManager)) // Require authentication for management APIs
{
// Tenant management
tenantHandler.RegisterRoutes(api.Group("/tenants"))
Expand Down
2 changes: 2 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ server:
# JWT configuration
jwt:
secret: "your-super-secret-jwt-key-minimum-32-characters-long"
algorithm: "RS256" # RS256 (asymmetric, recommended) or HS256 (symmetric)
rsa_key_path: "" # directory to persist RSA key pair PEM files; empty = in-memory only

# Security configuration
security:
Expand Down
17 changes: 10 additions & 7 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package config
import (
"errors"
"fmt"
"os"
"time"

"github.com/spf13/viper"
Expand All @@ -23,7 +22,9 @@ type Config struct {
GinMode string

// JWT
JWTSecret string
JWTSecret string
JWTAlgorithm string // "RS256" or "HS256"
RSAKeyPath string // directory to persist RSA key pair (optional)

// Security
BcryptCost int
Expand Down Expand Up @@ -75,6 +76,8 @@ func Load() (*Config, error) {
viper.BindEnv("server.port", "PORT")
viper.BindEnv("server.gin_mode", "GIN_MODE")
viper.BindEnv("jwt.secret", "JWT_SECRET")
viper.BindEnv("jwt.algorithm", "JWT_ALGORITHM")
viper.BindEnv("jwt.rsa_key_path", "RSA_KEY_PATH")
viper.BindEnv("security.bcrypt_cost", "BCRYPT_COST")
viper.BindEnv("security.access_token_duration", "ACCESS_TOKEN_DURATION")
viper.BindEnv("security.refresh_token_duration", "REFRESH_TOKEN_DURATION")
Expand Down Expand Up @@ -102,10 +105,6 @@ func Load() (*Config, error) {
}
}

// Debug: Print environment variables
fmt.Printf("DEBUG: DATABASE_URL env var: %s\n", os.Getenv("DATABASE_URL"))
fmt.Printf("DEBUG: Config database.url: %s\n", viper.GetString("database.url"))

return &Config{
// Database
DatabaseURL: viper.GetString("database.url"),
Expand All @@ -119,7 +118,9 @@ func Load() (*Config, error) {
GinMode: viper.GetString("server.gin_mode"),

// JWT
JWTSecret: viper.GetString("jwt.secret"),
JWTSecret: viper.GetString("jwt.secret"),
JWTAlgorithm: viper.GetString("jwt.algorithm"),
RSAKeyPath: viper.GetString("jwt.rsa_key_path"),

// Security
BcryptCost: viper.GetInt("security.bcrypt_cost"),
Expand Down Expand Up @@ -165,6 +166,8 @@ func setDefaults() {

// JWT defaults
viper.SetDefault("jwt.secret", "your-super-secret-jwt-key-minimum-32-characters-long")
viper.SetDefault("jwt.algorithm", "RS256")
viper.SetDefault("jwt.rsa_key_path", "")

// Security defaults
viper.SetDefault("security.bcrypt_cost", 12)
Expand Down
167 changes: 167 additions & 0 deletions internal/crypto/rsa_keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package crypto

import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"math/big"
"os"
"path/filepath"

"github.com/google/uuid"
)

const (
rsaKeySize = 2048
privateKeyFile = "private.pem"
publicKeyFile = "public.pem"
)

// KeyManager manages an RSA key pair used for JWT signing (RS256).
// Keys are generated at startup; when RSAKeyPath is configured they are
// persisted to disk and reloaded on subsequent starts.
type KeyManager struct {
privateKey *rsa.PrivateKey
kid string // key ID — stable identifier embedded in JWT header
}

// NewKeyManager creates a KeyManager. When keyPath is non-empty the manager
// tries to load existing PEM files from that directory; if they don't exist it
// generates a fresh pair and writes them there.
func NewKeyManager(keyPath string) (*KeyManager, error) {
km := &KeyManager{}

if keyPath != "" {
if err := os.MkdirAll(keyPath, 0700); err != nil {
return nil, fmt.Errorf("create RSA key directory: %w", err)
}

privPath := filepath.Join(keyPath, privateKeyFile)
if _, err := os.Stat(privPath); err == nil {
// Load existing key pair
if err := km.load(keyPath); err != nil {
return nil, fmt.Errorf("load RSA keys: %w", err)
}
return km, nil
}
}

// Generate a new key pair
if err := km.generate(); err != nil {
return nil, fmt.Errorf("generate RSA key pair: %w", err)
}

if keyPath != "" {
if err := km.persist(keyPath); err != nil {
// Non-fatal — in-memory keys still work
fmt.Printf("Warning: could not persist RSA keys to %s: %v\n", keyPath, err)
}
}

return km, nil
}

// PrivateKey returns the RSA private key (used for signing).
func (km *KeyManager) PrivateKey() *rsa.PrivateKey {
return km.privateKey
}

// PublicKey returns the RSA public key (used for verification).
func (km *KeyManager) PublicKey() *rsa.PublicKey {
return &km.privateKey.PublicKey
}

// KID returns the stable key ID embedded in JWT headers.
func (km *KeyManager) KID() string {
return km.kid
}

// JWK returns a JSON Web Key representation of the public key suitable for
// serving on the /.well-known/jwks.json endpoint.
func (km *KeyManager) JWK() map[string]interface{} {
pub := &km.privateKey.PublicKey

// Encode the modulus (n) and exponent (e) as Base64URL per RFC 7518 §6.3
n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes())
eBig := big.NewInt(int64(pub.E))
e := base64.RawURLEncoding.EncodeToString(eBig.Bytes())

return map[string]interface{}{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": km.kid,
"n": n,
"e": e,
}
}

// generate creates a new RSA-2048 key pair and assigns a random KID.
func (km *KeyManager) generate() error {
key, err := rsa.GenerateKey(rand.Reader, rsaKeySize)
if err != nil {
return err
}
km.privateKey = key
km.kid = uuid.New().String()
return nil
}

// persist writes the key pair and KID to keyPath as PEM files.
func (km *KeyManager) persist(keyPath string) error {
privBytes := x509.MarshalPKCS1PrivateKey(km.privateKey)
privPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privBytes})
if err := os.WriteFile(filepath.Join(keyPath, privateKeyFile), privPEM, 0600); err != nil {
return err
}

pubBytes, err := x509.MarshalPKIXPublicKey(&km.privateKey.PublicKey)
if err != nil {
return err
}
pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
if err := os.WriteFile(filepath.Join(keyPath, publicKeyFile), pubPEM, 0644); err != nil {
return err
}

// Persist the KID alongside the keys so it is stable across restarts.
kidPath := filepath.Join(keyPath, "kid.txt")
if err := os.WriteFile(kidPath, []byte(km.kid), 0644); err != nil {
return err
}

return nil
}

// load reads a key pair and KID from keyPath.
func (km *KeyManager) load(keyPath string) error {
privData, err := os.ReadFile(filepath.Join(keyPath, privateKeyFile))
if err != nil {
return err
}

block, _ := pem.Decode(privData)
if block == nil {
return fmt.Errorf("invalid PEM block in %s", privateKeyFile)
}

key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return err
}
km.privateKey = key

// Load KID; generate a new one if missing (first migration from older version).
kidPath := filepath.Join(keyPath, "kid.txt")
kidData, err := os.ReadFile(kidPath)
if err != nil {
km.kid = uuid.New().String()
} else {
km.kid = string(kidData)
}

return nil
}
93 changes: 93 additions & 0 deletions internal/crypto/rsa_keys_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package crypto

import (
"encoding/base64"
"math/big"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNewKeyManager_InMemory(t *testing.T) {
km, err := NewKeyManager("")
require.NoError(t, err)
assert.NotNil(t, km.PrivateKey())
assert.NotNil(t, km.PublicKey())
assert.NotEmpty(t, km.KID())
}

func TestNewKeyManager_GeneratesUniqueKIDs(t *testing.T) {
km1, _ := NewKeyManager("")
km2, _ := NewKeyManager("")
assert.NotEqual(t, km1.KID(), km2.KID())
}

func TestKeyManager_JWK_Shape(t *testing.T) {
km, err := NewKeyManager("")
require.NoError(t, err)

jwk := km.JWK()

assert.Equal(t, "RSA", jwk["kty"])
assert.Equal(t, "sig", jwk["use"])
assert.Equal(t, "RS256", jwk["alg"])
assert.Equal(t, km.KID(), jwk["kid"])

n, ok := jwk["n"].(string)
require.True(t, ok, "n should be a string")
assert.NotEmpty(t, n)

e, ok := jwk["e"].(string)
require.True(t, ok, "e should be a string")
assert.NotEmpty(t, e)

// Verify that e decodes correctly (standard exponent 65537)
eBytes, err := base64.RawURLEncoding.DecodeString(e)
require.NoError(t, err)
eBig := new(big.Int).SetBytes(eBytes)
assert.Equal(t, int64(65537), eBig.Int64())
}

func TestNewKeyManager_PersistAndReload(t *testing.T) {
dir := t.TempDir()

// First call: generate and persist
km1, err := NewKeyManager(dir)
require.NoError(t, err)

// Key files must exist
assert.FileExists(t, filepath.Join(dir, privateKeyFile))
assert.FileExists(t, filepath.Join(dir, publicKeyFile))
assert.FileExists(t, filepath.Join(dir, "kid.txt"))

// Second call: load from disk
km2, err := NewKeyManager(dir)
require.NoError(t, err)

// Same KID means same key was loaded
assert.Equal(t, km1.KID(), km2.KID())

// Public key modulus must match
n1 := km1.PublicKey().N.Bytes()
n2 := km2.PublicKey().N.Bytes()
assert.Equal(t, n1, n2, "reloaded public key should match original")
}

func TestNewKeyManager_RespectsPrivateKeyFilePermissions(t *testing.T) {
dir := t.TempDir()
_, err := NewKeyManager(dir)
require.NoError(t, err)

info, err := os.Stat(filepath.Join(dir, privateKeyFile))
require.NoError(t, err)
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "private key should be owner-read-only")
}

func TestKeyManager_RSAKeySize(t *testing.T) {
km, err := NewKeyManager("")
require.NoError(t, err)
assert.Equal(t, rsaKeySize, km.PrivateKey().N.BitLen())
}
Loading