diff --git a/.env.example b/.env.example index f901a1a..e3e39a9 100644 --- a/.env.example +++ b/.env.example @@ -21,7 +21,10 @@ SESSION_SECRET=session-secret-change-in-production # JWT Signing Algorithm # Options: HS256 (default, symmetric), RS256 (RSA), ES256 (ECDSA P-256) # JWT_SIGNING_ALGORITHM=HS256 -# JWT_PRIVATE_KEY_PATH= # Required for RS256/ES256: path to PEM private key +# For RS256/ES256 supply the private key via at least one of the following: +# JWT_PRIVATE_KEY_PATH= # Path to PEM private key file +# JWT_PRIVATE_KEY_PEM= # Inline PEM content (for K8s Secrets / GitHub Actions). +# # Takes precedence over JWT_PRIVATE_KEY_PATH when both are set. # JWT_KEY_ID= # Optional: kid header value (auto-generated from key fingerprint) # JWT Token Expiration diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 39800f1..40e83a1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -290,18 +290,30 @@ AuthGate supports three JWT signing algorithms: ### Configuration +For RS256/ES256 you must supply the private key via **at least one** of two environment variables: + +| Variable | Use when | +| ---------------------- | ------------------------------------------------------------------ | +| `JWT_PRIVATE_KEY_PATH` | Key is available as a file on disk (bare-metal, Docker volume) | +| `JWT_PRIVATE_KEY_PEM` | Key is injected as a string (Kubernetes Secret, GitHub Actions, Fly.io, Cloud Run) | + +When both are set, `JWT_PRIVATE_KEY_PEM` wins and AuthGate logs a warning on startup. + ```bash # HS256 (default — no additional config needed) JWT_SIGNING_ALGORITHM=HS256 -# RS256 +# RS256 — load key from disk JWT_SIGNING_ALGORITHM=RS256 JWT_PRIVATE_KEY_PATH=/path/to/rsa-private.pem JWT_KEY_ID= # Optional: auto-generated from key fingerprint -# ES256 +# ES256 — load key from inline PEM (env var holds the full PEM content incl. newlines) JWT_SIGNING_ALGORITHM=ES256 -JWT_PRIVATE_KEY_PATH=/path/to/ec-private.pem +JWT_PRIVATE_KEY_PEM="-----BEGIN EC PRIVATE KEY----- +MHcCAQEEI...... +-----END EC PRIVATE KEY----- +" JWT_KEY_ID= # Optional: auto-generated from key fingerprint ``` @@ -315,6 +327,62 @@ openssl genrsa -out rsa-private.pem 2048 openssl ecparam -genkey -name prime256v1 -noout -out ec-private.pem ``` +### Loading Keys in Containerized Deployments + +`JWT_PRIVATE_KEY_PEM` lets you pass the full PEM string through environment variables, +which is the native secret-delivery mechanism on most container platforms. Both +GitHub Actions Secrets and Kubernetes Secrets preserve newlines, so no base64 encoding +is required. + +**GitHub Actions** + +Store the PEM in a repository secret (e.g. `JWT_SIGNING_KEY`) — GitHub's secret editor +preserves multi-line input as-is. Then inject it at runtime: + +```yaml +- name: Run AuthGate + env: + JWT_SIGNING_ALGORITHM: RS256 + JWT_PRIVATE_KEY_PEM: ${{ secrets.JWT_SIGNING_KEY }} + run: ./bin/authgate server +``` + +**Kubernetes** + +Store the PEM in a `Secret` and expose it via `env.valueFrom.secretKeyRef`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: authgate-jwt +type: Opaque +stringData: + private-key.pem: | + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEI... + -----END EC PRIVATE KEY----- +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: authgate +spec: + template: + spec: + containers: + - name: authgate + image: authgate:latest + env: + - name: JWT_SIGNING_ALGORITHM + value: ES256 + - name: JWT_PRIVATE_KEY_PEM + valueFrom: + secretKeyRef: + name: authgate-jwt + key: private-key.pem +``` + ### JWKS Endpoint When using RS256 or ES256, AuthGate exposes the public key at: diff --git a/internal/bootstrap/providers.go b/internal/bootstrap/providers.go index 749a905..1a0ed47 100644 --- a/internal/bootstrap/providers.go +++ b/internal/bootstrap/providers.go @@ -1,6 +1,7 @@ package bootstrap import ( + "crypto" "log" "github.com/go-authgate/authgate/internal/auth" @@ -52,10 +53,27 @@ func initializeTokenProvider(cfg *config.Config) *token.LocalTokenProvider { log.Fatalf("Unsupported JWT_SIGNING_ALGORITHM: %q", cfg.JWTSigningAlgorithm) } - // Load asymmetric key - privateKey, err := token.LoadSigningKey(cfg.JWTPrivateKeyPath) - if err != nil { - log.Fatalf("Failed to load JWT private key from %s: %v", cfg.JWTPrivateKeyPath, err) + // Prefer inline PEM over file path so containerized deployments (K8s Secrets, + // GitHub Actions) can override an image-baked default without rewriting the file. + var ( + privateKey crypto.Signer + err error + ) + if cfg.JWTPrivateKeyPEM != "" { + if cfg.JWTPrivateKeyPath != "" { + log.Printf( + "Warning: both JWT_PRIVATE_KEY_PEM and JWT_PRIVATE_KEY_PATH are set; using PEM", + ) + } + privateKey, err = token.ParseSigningKey([]byte(cfg.JWTPrivateKeyPEM)) + if err != nil { + log.Fatalf("Failed to parse JWT_PRIVATE_KEY_PEM: %v", err) + } + } else { + privateKey, err = token.LoadSigningKey(cfg.JWTPrivateKeyPath) + if err != nil { + log.Fatalf("Failed to load JWT private key from %s: %v", cfg.JWTPrivateKeyPath, err) + } } // Derive kid if not explicitly set diff --git a/internal/config/config.go b/internal/config/config.go index 1556eec..c349f14 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,7 +60,8 @@ type Config struct { JWTSecret string JWTExpiration time.Duration JWTSigningAlgorithm string // "HS256" (default), "RS256", or "ES256" - JWTPrivateKeyPath string // PEM file path (required for RS256/ES256) + JWTPrivateKeyPath string // PEM file path (required for RS256/ES256 when PEM content is not set) + JWTPrivateKeyPEM string // PEM content (alternative to JWTPrivateKeyPath; takes precedence if both are set) JWTKeyID string // "kid" header for JWKS key rotation (auto-generated if empty) JWTExpirationJitter time.Duration // Max random jitter added to access token expiry (default: 30m) @@ -308,6 +309,7 @@ func Load() *Config { JWTExpiration: jwtExpiration, JWTSigningAlgorithm: getEnv("JWT_SIGNING_ALGORITHM", AlgHS256), JWTPrivateKeyPath: getEnv("JWT_PRIVATE_KEY_PATH", ""), + JWTPrivateKeyPEM: getEnv("JWT_PRIVATE_KEY_PEM", ""), JWTKeyID: getEnv("JWT_KEY_ID", ""), JWTExpirationJitter: getEnvDuration("JWT_EXPIRATION_JITTER", 30*time.Minute), SessionSecret: getEnv("SESSION_SECRET", "session-secret-change-in-production"), @@ -604,9 +606,9 @@ func (c *Config) Validate() error { case "", AlgHS256: // default, no key file required case AlgRS256, AlgES256: - if c.JWTPrivateKeyPath == "" { + if c.JWTPrivateKeyPath == "" && c.JWTPrivateKeyPEM == "" { return fmt.Errorf( - "JWT_PRIVATE_KEY_PATH is required when JWT_SIGNING_ALGORITHM=%s", + "JWT_PRIVATE_KEY_PATH or JWT_PRIVATE_KEY_PEM is required when JWT_SIGNING_ALGORITHM=%s", c.JWTSigningAlgorithm, ) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e88e27c..2fb4e86 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -101,6 +101,7 @@ func TestConfig_Validate_JWTSigningAlgorithm(t *testing.T) { name string algorithm string keyPath string + keyPEM string expectError bool errorMsg string }{ @@ -115,10 +116,10 @@ func TestConfig_Validate_JWTSigningAlgorithm(t *testing.T) { expectError: false, }, { - name: "RS256 requires key path", + name: "RS256 requires key path or PEM", algorithm: "RS256", expectError: true, - errorMsg: "JWT_PRIVATE_KEY_PATH is required", + errorMsg: "JWT_PRIVATE_KEY_PATH or JWT_PRIVATE_KEY_PEM is required", }, { name: "RS256 with key path OK", @@ -127,10 +128,23 @@ func TestConfig_Validate_JWTSigningAlgorithm(t *testing.T) { expectError: false, }, { - name: "ES256 requires key path", + name: "RS256 with inline PEM OK", + algorithm: "RS256", + keyPEM: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n", + expectError: false, + }, + { + name: "RS256 with both path and PEM OK (PEM takes precedence at runtime)", + algorithm: "RS256", + keyPath: "/some/key.pem", + keyPEM: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n", + expectError: false, + }, + { + name: "ES256 requires key path or PEM", algorithm: "ES256", expectError: true, - errorMsg: "JWT_PRIVATE_KEY_PATH is required", + errorMsg: "JWT_PRIVATE_KEY_PATH or JWT_PRIVATE_KEY_PEM is required", }, { name: "ES256 with key path OK", @@ -138,6 +152,12 @@ func TestConfig_Validate_JWTSigningAlgorithm(t *testing.T) { keyPath: "/some/key.pem", expectError: false, }, + { + name: "ES256 with inline PEM OK", + algorithm: "ES256", + keyPEM: "-----BEGIN EC PRIVATE KEY-----\nabc\n-----END EC PRIVATE KEY-----\n", + expectError: false, + }, { name: "unsupported algorithm", algorithm: "PS256", @@ -151,6 +171,7 @@ func TestConfig_Validate_JWTSigningAlgorithm(t *testing.T) { cfg := validBaseConfig() cfg.JWTSigningAlgorithm = tt.algorithm cfg.JWTPrivateKeyPath = tt.keyPath + cfg.JWTPrivateKeyPEM = tt.keyPEM err := cfg.Validate() if tt.expectError { require.Error(t, err) diff --git a/internal/token/key.go b/internal/token/key.go index 88912cd..4c03bd5 100644 --- a/internal/token/key.go +++ b/internal/token/key.go @@ -8,19 +8,15 @@ import ( "crypto/x509" "encoding/base64" "encoding/pem" + "errors" "fmt" "os" ) -// LoadSigningKey reads a PEM file and returns the parsed private key. +// ParseSigningKey parses PEM-encoded data into a supported private key. // Supports RSA (PKCS#1 / PKCS#8) and ECDSA (SEC1 / PKCS#8). -// All PEM blocks in the file are tried in order until a supported key is found. -func LoadSigningKey(path string) (crypto.Signer, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read key file: %w", err) - } - +// All PEM blocks are tried in order until a supported key is found. +func ParseSigningKey(data []byte) (crypto.Signer, error) { rest := data foundBlocks := false for { @@ -56,9 +52,23 @@ func LoadSigningKey(path string) (crypto.Signer, error) { } if !foundBlocks { - return nil, fmt.Errorf("no PEM block found in %s", path) + return nil, errors.New("no PEM block found in key data") + } + return nil, errors.New("no supported private key found in key data") +} + +// LoadSigningKey reads a PEM file and returns the parsed private key. +// Supports RSA (PKCS#1 / PKCS#8) and ECDSA (SEC1 / PKCS#8). +func LoadSigningKey(path string) (crypto.Signer, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read key file: %w", err) + } + key, err := ParseSigningKey(data) + if err != nil { + return nil, fmt.Errorf("parse key file %s: %w", path, err) } - return nil, fmt.Errorf("no supported private key found in %s", path) + return key, nil } // DeriveKeyID computes a deterministic kid from the SHA-256 hash of the diff --git a/internal/token/key_test.go b/internal/token/key_test.go index d15c2a3..b7a9df6 100644 --- a/internal/token/key_test.go +++ b/internal/token/key_test.go @@ -121,6 +121,98 @@ func TestLoadSigningKey_MultiBlock_ECAfterUnknown(t *testing.T) { assert.True(t, ok, "expected *ecdsa.PrivateKey from multi-block PEM") } +func TestParseSigningKey_RSA_PKCS1(t *testing.T) { + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(rsaKey), + }) + + key, err := ParseSigningKey(pemBytes) + require.NoError(t, err) + _, ok := key.(*rsa.PrivateKey) + assert.True(t, ok, "expected *rsa.PrivateKey, got %T", key) +} + +func TestParseSigningKey_RSA_PKCS8(t *testing.T) { + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + der, err := x509.MarshalPKCS8PrivateKey(rsaKey) + require.NoError(t, err) + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + + key, err := ParseSigningKey(pemBytes) + require.NoError(t, err) + _, ok := key.(*rsa.PrivateKey) + assert.True(t, ok, "expected *rsa.PrivateKey, got %T", key) +} + +func TestParseSigningKey_ECDSA_SEC1(t *testing.T) { + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + der, err := x509.MarshalECPrivateKey(ecKey) + require.NoError(t, err) + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) + + key, err := ParseSigningKey(pemBytes) + require.NoError(t, err) + loaded, ok := key.(*ecdsa.PrivateKey) + assert.True(t, ok, "expected *ecdsa.PrivateKey, got %T", key) + assert.Equal(t, elliptic.P256(), loaded.Curve) +} + +func TestParseSigningKey_ECDSA_PKCS8(t *testing.T) { + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + der, err := x509.MarshalPKCS8PrivateKey(ecKey) + require.NoError(t, err) + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + + key, err := ParseSigningKey(pemBytes) + require.NoError(t, err) + _, ok := key.(*ecdsa.PrivateKey) + assert.True(t, ok, "expected *ecdsa.PrivateKey, got %T", key) +} + +func TestParseSigningKey_EmptyInput(t *testing.T) { + _, err := ParseSigningKey(nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no PEM block found") +} + +func TestParseSigningKey_InvalidPEM(t *testing.T) { + _, err := ParseSigningKey([]byte("not a pem blob")) + require.Error(t, err) + assert.Contains(t, err.Error(), "no PEM block found") +} + +func TestParseSigningKey_UnsupportedFormat(t *testing.T) { + pemBytes := pem.EncodeToMemory( + &pem.Block{Type: "UNKNOWN KEY", Bytes: []byte("garbage-key-data")}, + ) + + _, err := ParseSigningKey(pemBytes) + require.Error(t, err) + assert.Contains(t, err.Error(), "no supported private key found") +} + +func TestParseSigningKey_MultiBlock(t *testing.T) { + // PEM with a non-key block first (e.g. EC PARAMETERS), followed by the real EC key. + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + ecDER, err := x509.MarshalECPrivateKey(ecKey) + require.NoError(t, err) + + buf := pem.EncodeToMemory(&pem.Block{Type: "EC PARAMETERS", Bytes: []byte("params")}) + buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: ecDER})...) + + key, err := ParseSigningKey(buf) + require.NoError(t, err) + _, ok := key.(*ecdsa.PrivateKey) + assert.True(t, ok, "expected *ecdsa.PrivateKey from multi-block PEM") +} + func TestDeriveKeyID_RSA(t *testing.T) { rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err)